diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml
index f46881c5b..cd8fb385a 100644
--- a/.github/workflows/desktop-connect-discovery-guard.yml
+++ b/.github/workflows/desktop-connect-discovery-guard.yml
@@ -75,6 +75,12 @@ jobs:
- name: Install locked dependencies
run: npm ci
+ - name: Install native Linux package and credential tools
+ 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
+
- name: Verify encoded Windows PowerShell ACL helper success streams
if: matrix.platform == 'win32'
run: npm run test:windows-fixture-acl -w @propr/desktop
@@ -94,11 +100,59 @@ jobs:
sudo chown root:root "$sandbox"
sudo chmod 4755 "$sandbox"
test "$(stat -c '%U:%G:%a' "$sandbox")" = 'root:root:4755'
- dbus-run-session -- xvfb-run --auto-servernum npm run smoke:connect-package -w @propr/desktop
+ keyring_root="$(mktemp -d)"
+ trap 'rm -rf -- "$keyring_root"' EXIT
+ dbus-run-session -- bash -euo pipefail -c '
+ export XDG_DATA_HOME="$1"
+ export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1"
+ eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)"
+ xvfb-run --auto-servernum npm run smoke:connect-package -w @propr/desktop
+ ' bash "$keyring_root"
- name: Run packaged Darwin main-to-renderer discovery
if: matrix.platform == 'darwin'
- run: npm run smoke:connect-package -w @propr/desktop
+ shell: bash
+ run: |
+ set -euo pipefail
+ keychain_root="$(mktemp -d)"
+ keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db"
+ keychain_password="$(openssl rand -hex 32)"
+ original_keychains=()
+ while IFS= read -r keychain; do
+ keychain="${keychain#"${keychain%%[![:space:]]*}"}"
+ keychain="${keychain#\"}"
+ keychain="${keychain%\"}"
+ if [[ -n "$keychain" ]]; then
+ original_keychains+=("$keychain")
+ fi
+ done < <(security list-keychains -d user)
+ IFS= read -r original_default < <(security default-keychain -d user)
+ original_default="${original_default#"${original_default%%[![:space:]]*}"}"
+ original_default="${original_default#\"}"
+ original_default="${original_default%\"}"
+ cleanup_keychain() {
+ if (( ${#original_keychains[@]} > 0 )); then
+ security list-keychains -d user -s "${original_keychains[@]}" || true
+ else
+ security list-keychains -d user -s || true
+ fi
+ if [[ -n "$original_default" ]]; then
+ security default-keychain -d user -s "$original_default" || true
+ fi
+ security delete-keychain "$keychain_path" || true
+ rm -rf -- "$keychain_root"
+ }
+ trap cleanup_keychain EXIT
+ security create-keychain -p "$keychain_password" "$keychain_path"
+ security set-keychain-settings -lut 21600 "$keychain_path"
+ security unlock-keychain -p "$keychain_password" "$keychain_path"
+ security list-keychains -d user -s "$keychain_path"
+ security default-keychain -d user -s "$keychain_path"
+ unset keychain_password
+ safe_storage_secret="$(openssl rand -hex 32)"
+ security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "$safe_storage_secret" -A "$keychain_path"
+ unset safe_storage_secret
+ npm run smoke:connect-package -w @propr/desktop
- name: Run packaged Windows main-to-renderer discovery as an ordinary user
if: matrix.platform == 'win32'
diff --git a/apps/desktop/scripts/packaged-connect-launch.mjs b/apps/desktop/scripts/packaged-connect-launch.mjs
new file mode 100644
index 000000000..bf2f16544
--- /dev/null
+++ b/apps/desktop/scripts/packaged-connect-launch.mjs
@@ -0,0 +1,13 @@
+export const createPackagedConnectLaunchArguments = ({ platform, userDataPath }) => Object.freeze([
+ '--disable-gpu',
+ `--user-data-dir=${userDataPath}`,
+ ...(platform === 'linux' ? ['--password-store=gnome-libsecret'] : []),
+]);
+
+/** Keep the tested lifecycle argv identical at the real packaged-binary spawn boundary. */
+export const spawnPackagedConnectBinary = ({
+ binaryPath,
+ launchArguments,
+ options,
+ spawn,
+}) => spawn(binaryPath, launchArguments, options);
diff --git a/apps/desktop/scripts/packaged-connect-launch.test.mjs b/apps/desktop/scripts/packaged-connect-launch.test.mjs
new file mode 100644
index 000000000..35fec5ec0
--- /dev/null
+++ b/apps/desktop/scripts/packaged-connect-launch.test.mjs
@@ -0,0 +1,55 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { describe, test } from 'node:test';
+import {
+ createPackagedConnectLaunchArguments,
+ spawnPackagedConnectBinary,
+} from './packaged-connect-launch.mjs';
+
+describe('packaged Connect launch boundary', () => {
+ test('passes the one effective Linux argv through the actual binary spawn', () => {
+ const launchArguments = createPackagedConnectLaunchArguments({
+ platform: 'linux',
+ userDataPath: '/tmp/propr-connect-smoke',
+ });
+ let invocation;
+ const child = {};
+ assert.equal(spawnPackagedConnectBinary({
+ binaryPath: '/package/propr-desktop',
+ launchArguments,
+ options: { shell: false },
+ spawn: (file, args, options) => {
+ invocation = { file, args, options };
+ return child;
+ },
+ }), child);
+ assert.deepEqual(invocation, {
+ file: '/package/propr-desktop',
+ args: [
+ '--disable-gpu',
+ '--user-data-dir=/tmp/propr-connect-smoke',
+ '--password-store=gnome-libsecret',
+ ],
+ options: { shell: false },
+ });
+ assert.equal(invocation.args, launchArguments);
+ });
+
+ test('does not add the Linux password-store selection on Darwin', () => {
+ assert.deepEqual(createPackagedConnectLaunchArguments({
+ platform: 'darwin',
+ userDataPath: '/tmp/propr-connect-smoke',
+ }), [
+ '--disable-gpu',
+ '--user-data-dir=/tmp/propr-connect-smoke',
+ ]);
+ });
+
+ test('the lifecycle and real binary spawn share the derived argv source', async () => {
+ const source = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8');
+ assert.match(source, /const launchArguments = createPackagedConnectLaunchArguments\(\{/u);
+ assert.match(source, /spawnPackagedConnectBinary\(\{[\s\S]*?launchArguments: args,/u);
+ assert.match(source, /runPackagedConnectLifecycle\(\{[\s\S]*?args: launchArguments,/u);
+ assert.doesNotMatch(source, /spawn\(binaryPath, \['--disable-gpu'/u);
+ });
+});
diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs
index 93deaaffb..59b4849c9 100644
--- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs
+++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs
@@ -5,6 +5,12 @@ import { TextDecoder } from 'node:util';
import { fileURLToPath } from 'node:url';
export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready';
+export const CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone';
+export const CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage';
+export const CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure';
+export const CONNECT_NETWORK_PERMISSION_EVENT = 'desktop.renderer.connect_network_permission';
+export const CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation';
+export const CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership';
export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024;
export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20;
@@ -23,6 +29,12 @@ const diagnosticEvents = new Set([
'desktop.log.write_failed',
'desktop.main_process.uncaught_exception',
CONNECT_READY_EVENT,
+ CONNECT_DISCOVERY_MILESTONE_EVENT,
+ CONNECT_JOURNEY_STAGE_EVENT,
+ CONNECT_JOURNEY_FAILURE_EVENT,
+ CONNECT_NETWORK_PERMISSION_EVENT,
+ CONNECT_JOURNEY_OPERATION_EVENT,
+ CONNECT_RENDERER_OWNERSHIP_EVENT,
'desktop.renderer.connect_discovery.phase',
'desktop.renderer.connect_discovery.status',
'desktop.renderer.gone',
@@ -40,6 +52,42 @@ const diagnosticCodes = new Set([
'OPERATION_FAILED',
'UNCAUGHT_EXCEPTION',
]);
+const journeyStageCodes = new Set([
+ 'JOURNEY_DISCOVERY_RENDERER',
+ 'JOURNEY_DISCOVERY_VALIDATED',
+ 'JOURNEY_STORAGE_BACKEND',
+ 'JOURNEY_NEGATIVE_MALFORMED',
+ 'JOURNEY_NEGATIVE_OVERSIZED',
+ 'JOURNEY_NEGATIVE_EXPIRY',
+ 'JOURNEY_NEGATIVE_CANCEL',
+ 'JOURNEY_NEGATIVE_STATE',
+ 'JOURNEY_PAIR_MANUAL_FORM',
+ 'JOURNEY_PAIR_BROWSER_APPROVAL',
+ 'JOURNEY_PAIR_ACTIVATION_DASHBOARD',
+ 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED',
+ 'JOURNEY_PAIR_CREDENTIAL_COMMITTED',
+ 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY',
+ 'JOURNEY_PAIR_ACTIVATION_COMMITTED',
+ 'JOURNEY_PAIR_ACTIVATION_PUBLISHED',
+ 'JOURNEY_PAIR_REACT_CONNECTED',
+ 'JOURNEY_PAIR_TRANSPORT',
+ 'JOURNEY_PAIR_COMPLETE',
+ 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD',
+ 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY',
+ 'JOURNEY_REPROBE_ACTIVATION_COMMITTED',
+ 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED',
+ 'JOURNEY_REPROBE_REACT_CONNECTED',
+ 'JOURNEY_REPROBE_TRANSPORT',
+ 'JOURNEY_REPROBE_COMPLETE',
+]);
+const journeyFailurePhases = new Set(['pair', 'reprobe']);
+const journeyFailureReasons = new Set([
+ 'APPROVAL_REJECTED',
+ 'JOURNEY_FAILED',
+ 'RENDERER_STAGE_TIMEOUT',
+ 'RENDERER_STATE_TIMEOUT',
+ 'TRANSPORT_EVIDENCE_TIMEOUT',
+]);
const diagnosticPhases = new Set([
'config-read',
'addon-integrity-type',
@@ -60,26 +108,135 @@ const diagnosticCategories = new Set([
'type-mismatch',
'unexpected',
]);
+const networkPermissionCategories = new Set([
+ 'local-network-access',
+ 'local-network',
+ 'loopback-network',
+]);
+const networkPermissionDecisions = new Set(['check', 'request']);
+const networkPermissionBooleanFields = [
+ 'activeBindingCurrent',
+ 'webContentsPresent',
+ 'webContentsEqualsMainWindow',
+ 'mainWindowPresent',
+ 'isMainFrame',
+ 'requestingUrlPresent',
+ 'requestingUrlTrusted',
+ 'rendererDocumentUrlTrusted',
+ 'requestingOriginAuthorityValid',
+ 'requestingOriginAuthorityEqual',
+];
+const journeyOperations = new Set(['PROFILE_SAVE', 'PAIR', 'PROBE', 'ACTIVATE']);
+const journeyOperationStatuses = new Set([
+ 'COMPLETED', 'READY', 'AUTHENTICATION_REQUIRED', 'INCOMPATIBLE', 'OFFLINE', 'REJECTED',
+]);
+const rendererOwnershipResourceCategories = new Set(['xhr', 'webSocket', 'other']);
+const rendererOwnershipBooleanFields = [
+ 'mainRendererPresent',
+ 'mainRendererLive',
+ 'webContentsIdMatches',
+ 'webContentsAbsentOrMatches',
+ 'mainFrameLive',
+ 'rendererDocumentTrusted',
+ 'rendererDocumentAuthorityEqual',
+ 'frameOmitted',
+ 'framePresent',
+ 'frameMatchesMainFrame',
+ 'frameExplicitlyForeign',
+ 'rendererOwned',
+];
+
+const boundedNetworkPermissionEvidence = record => {
+ if (record.schemaVersion !== 1
+ || !networkPermissionCategories.has(record.permissionCategory)
+ || !networkPermissionDecisions.has(record.decision)
+ || typeof record.allowed !== 'boolean'
+ || networkPermissionBooleanFields.some(field => typeof record[field] !== 'boolean')) return {};
+ return {
+ schemaVersion: 1,
+ permissionCategory: record.permissionCategory,
+ decision: record.decision,
+ allowed: record.allowed,
+ ...Object.fromEntries(networkPermissionBooleanFields.map(field => [field, record[field]])),
+ };
+};
-export const boundedChildDiagnostics = records => records.flatMap(record => {
- if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return [];
- const nestedCode = record.error && typeof record.error === 'object' ? record.error.code : undefined;
- const candidateCode = typeof record.code === 'string' ? record.code : nestedCode;
- const phase = typeof record.phase === 'string' ? record.phase : undefined;
- const substep = typeof record.substep === 'string' ? record.substep : undefined;
- const category = typeof record.category === 'string' ? record.category : undefined;
- return [{
- event: record.event,
- ...(diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode)
- ? {
- phase,
- code: candidateCode,
- ...(candidateCode === 'FAILED' && diagnosticSubsteps.has(substep) ? { substep } : {}),
- ...(candidateCode === 'FAILED' && diagnosticCategories.has(category) ? { category } : {}),
- }
- : diagnosticCodes.has(candidateCode) ? { code: candidateCode } : {}),
- }];
-}).slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS);
+const boundedJourneyOperationEvidence = record => {
+ if (!journeyOperations.has(record.operation) || !journeyOperationStatuses.has(record.status)) return {};
+ return { operation: record.operation, status: record.status };
+};
+
+const boundedRendererOwnershipEvidence = record => {
+ if (record.schemaVersion !== 1
+ || !rendererOwnershipResourceCategories.has(record.resourceCategory)
+ || rendererOwnershipBooleanFields.some(field => typeof record[field] !== 'boolean')) return {};
+ return {
+ schemaVersion: 1,
+ resourceCategory: record.resourceCategory,
+ ...Object.fromEntries(rendererOwnershipBooleanFields.map(field => [field, record[field]])),
+ };
+};
+
+export const boundedChildDiagnostics = records => {
+ const diagnostics = records.flatMap(record => {
+ if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return [];
+ if (record.event === CONNECT_NETWORK_PERMISSION_EVENT) {
+ return [{ event: record.event, ...boundedNetworkPermissionEvidence(record) }];
+ }
+ if (record.event === CONNECT_JOURNEY_OPERATION_EVENT) {
+ return [{ event: record.event, ...boundedJourneyOperationEvidence(record) }];
+ }
+ if (record.event === CONNECT_JOURNEY_FAILURE_EVENT) {
+ return [{
+ event: record.event,
+ ...(journeyFailurePhases.has(record.phase)
+ && (record.stage === 'JOURNEY_NOT_STARTED' || journeyStageCodes.has(record.stage))
+ && journeyFailureReasons.has(record.reason)
+ ? { phase: record.phase, stage: record.stage, reason: record.reason }
+ : {}),
+ }];
+ }
+ if (record.event === CONNECT_RENDERER_OWNERSHIP_EVENT) {
+ return [{ event: record.event, ...boundedRendererOwnershipEvidence(record) }];
+ }
+ const nestedCode = record.error && typeof record.error === 'object' ? record.error.code : undefined;
+ const candidateCode = typeof record.code === 'string' ? record.code : nestedCode;
+ const phase = typeof record.phase === 'string' ? record.phase : undefined;
+ const substep = typeof record.substep === 'string' ? record.substep : undefined;
+ const category = typeof record.category === 'string' ? record.category : undefined;
+ return [{
+ event: record.event,
+ ...(journeyStageCodes.has(candidateCode)
+ && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT
+ || record.event === CONNECT_JOURNEY_STAGE_EVENT)
+ ? { code: candidateCode }
+ : diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode)
+ ? {
+ phase,
+ code: candidateCode,
+ ...(candidateCode === 'FAILED' && diagnosticSubsteps.has(substep) ? { substep } : {}),
+ ...(candidateCode === 'FAILED' && diagnosticCategories.has(category) ? { category } : {}),
+ }
+ : diagnosticCodes.has(candidateCode) ? { code: candidateCode } : {}),
+ }];
+ });
+ const bounded = diagnostics.slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS);
+ if (diagnostics.length > CHILD_DIAGNOSTIC_MAX_RECORDS) {
+ const latestCriticalEvidence = [
+ diagnostics.findLast(record => record.event === CONNECT_JOURNEY_OPERATION_EVENT),
+ diagnostics.findLast(record => record.event === CONNECT_RENDERER_OWNERSHIP_EVENT),
+ diagnostics.findLast(record => typeof record.code === 'string'
+ && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT
+ || record.event === CONNECT_JOURNEY_STAGE_EVENT)),
+ diagnostics.findLast(record => record.event === CONNECT_JOURNEY_FAILURE_EVENT),
+ ].filter(Boolean);
+ const withoutLatestCriticalEvidence = bounded.filter(record => !latestCriticalEvidence.includes(record));
+ return withoutLatestCriticalEvidence
+ .slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS - latestCriticalEvidence.length)
+ .concat(latestCriticalEvidence);
+ }
+ return bounded;
+};
const exactKeys = (record, expected) => {
const actual = Object.keys(record).sort();
@@ -670,6 +827,24 @@ export const preservePrimaryWithCleanup = (outcome, cleanup) => cleanup.ok ? out
secondary: [...new Set([...(outcome.secondary ?? []), cleanup.category])],
});
+export const createIdempotentJourneyFixtureClose = ({
+ closeSocketServer,
+ closeHttpServer,
+}) => {
+ let closePromise;
+ return () => {
+ closePromise ??= (async () => {
+ await closeSocketServer();
+ try {
+ await closeHttpServer();
+ } catch (error) {
+ if (error?.code !== 'ERR_SERVER_NOT_RUNNING') throw error;
+ }
+ })();
+ return closePromise;
+ };
+};
+
if (isIsolatedCleanupProcess) {
let input = '';
try {
diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
index 65c9639e0..0d1774698 100644
--- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
+++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
@@ -1,13 +1,20 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
-import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises';
+import { lstat, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { describe, test } from 'node:test';
import {
CHILD_CAPTURE_MAX_BYTES,
+ CONNECT_DISCOVERY_MILESTONE_EVENT,
+ CONNECT_JOURNEY_FAILURE_EVENT,
+ CONNECT_JOURNEY_STAGE_EVENT,
+ CONNECT_JOURNEY_OPERATION_EVENT,
+ CONNECT_NETWORK_PERMISSION_EVENT,
+ CONNECT_RENDERER_OWNERSHIP_EVENT,
CONNECT_READY_EVENT,
+ createIdempotentJourneyFixtureClose,
isExactReadyRecord,
preservePrimaryWithCleanup,
removeAuthorizedConnectFixture,
@@ -90,6 +97,19 @@ const run = ({ app = new FakeChild(), onApp, onKiller, ...options } = {}) => {
};
describe('packaged Connect bounded child lifecycle', () => {
+ test('requires exact three starts, three browser approvals, one poll, and one activation', async () => {
+ const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8');
+ const accounting = harness.slice(
+ harness.indexOf('if (discoveries.length !== 8'),
+ harness.indexOf('|| bootstrap.some(request => request.authorization !== null)'),
+ );
+ assert.match(accounting, /pairingStarts\.length !== 3/u);
+ assert.match(accounting, /pairingBrowsers\.length !== 3/u);
+ assert.match(accounting, /pairingPolls\.length !== 1/u);
+ assert.match(accounting, /pairingActivations\.length !== 1/u);
+ assert.doesNotMatch(accounting, /pairingPolls\.length < 3/u);
+ });
+
test('accepts an exact ready proof followed by a clean exit', async () => {
const { result, invocations } = await run({
onApp: app => {
@@ -106,6 +126,259 @@ describe('packaged Connect bounded child lifecycle', () => {
assert.equal(invocations.length, 1);
});
+ test('does not accept an intermediate discovery milestone as terminal readiness', async () => {
+ const { result } = await run({
+ onApp: app => {
+ app.write({
+ event: CONNECT_DISCOVERY_MILESTONE_EVENT,
+ code: 'JOURNEY_DISCOVERY_VALIDATED',
+ ignored: 'bounded-extra-field',
+ });
+ app.close(0, null);
+ },
+ });
+ assert.deepEqual(result, {
+ ok: false,
+ category: 'child-exit-before-ready',
+ capture: 'complete',
+ records: [{
+ event: CONNECT_DISCOVERY_MILESTONE_EVENT,
+ code: 'JOURNEY_DISCOVERY_VALIDATED',
+ }],
+ });
+ });
+
+ test('returns only exact allowlisted journey stages', async () => {
+ const { result } = await run({
+ onApp: app => {
+ app.write({
+ event: CONNECT_JOURNEY_STAGE_EVENT,
+ code: 'JOURNEY_PAIR_TRANSPORT',
+ url: 'https://not-returned.example.test/private',
+ });
+ app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'UNBOUNDED_STAGE' });
+ app.write({
+ event: CONNECT_JOURNEY_OPERATION_EVENT,
+ operation: 'PROBE',
+ status: 'AUTHENTICATION_REQUIRED',
+ error: 'not-returned',
+ });
+ app.write({
+ event: CONNECT_RENDERER_OWNERSHIP_EVENT,
+ schemaVersion: 1,
+ resourceCategory: 'xhr',
+ mainRendererPresent: true,
+ mainRendererLive: true,
+ webContentsIdMatches: true,
+ webContentsAbsentOrMatches: true,
+ mainFrameLive: true,
+ rendererDocumentTrusted: true,
+ rendererDocumentAuthorityEqual: true,
+ frameOmitted: true,
+ framePresent: false,
+ frameMatchesMainFrame: false,
+ frameExplicitlyForeign: false,
+ rendererOwned: false,
+ url: 'not-returned',
+ });
+ app.close(0, null);
+ },
+ });
+ assert.equal(result.category, 'child-exit-before-ready');
+ assert.deepEqual(result.records, [
+ { event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_TRANSPORT' },
+ { event: CONNECT_JOURNEY_STAGE_EVENT },
+ {
+ event: CONNECT_JOURNEY_OPERATION_EVENT,
+ operation: 'PROBE',
+ status: 'AUTHENTICATION_REQUIRED',
+ },
+ {
+ event: CONNECT_RENDERER_OWNERSHIP_EVENT,
+ schemaVersion: 1,
+ resourceCategory: 'xhr',
+ mainRendererPresent: true,
+ mainRendererLive: true,
+ webContentsIdMatches: true,
+ webContentsAbsentOrMatches: true,
+ mainFrameLive: true,
+ rendererDocumentTrusted: true,
+ rendererDocumentAuthorityEqual: true,
+ frameOmitted: true,
+ framePresent: false,
+ frameMatchesMainFrame: false,
+ frameExplicitlyForeign: false,
+ rendererOwned: false,
+ },
+ ]);
+ assert.doesNotMatch(JSON.stringify(result), /not-returned|UNBOUNDED_STAGE|url|error/u);
+ });
+
+ test('retains the latest bounded journey stage when earlier diagnostics fill the cap', async () => {
+ const { result } = await run({
+ onApp: app => {
+ for (let index = 0; index < 20; index += 1) {
+ app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' });
+ }
+ app.write({
+ event: CONNECT_JOURNEY_OPERATION_EVENT,
+ operation: 'ACTIVATE',
+ status: 'REJECTED',
+ error: 'not-returned',
+ });
+ app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD' });
+ app.close(0, null);
+ },
+ });
+ assert.equal(result.records.length, 20);
+ assert.deepEqual(result.records.at(-2), {
+ event: CONNECT_JOURNEY_OPERATION_EVENT,
+ operation: 'ACTIVATE',
+ status: 'REJECTED',
+ });
+ assert.deepEqual(result.records.at(-1), {
+ event: CONNECT_JOURNEY_STAGE_EVENT,
+ code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD',
+ });
+ assert.doesNotMatch(JSON.stringify(result), /not-returned/u);
+ });
+
+ test('retains only fixed terminal journey failure evidence when diagnostics fill the cap', async () => {
+ const { result } = await run({
+ onApp: app => {
+ for (let index = 0; index < 20; index += 1) {
+ app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' });
+ }
+ app.write({
+ event: CONNECT_JOURNEY_FAILURE_EVENT,
+ phase: 'pair',
+ stage: 'JOURNEY_PAIR_REACT_CONNECTED',
+ reason: 'RENDERER_STATE_TIMEOUT',
+ error: 'secret-SENTINEL',
+ url: 'https://not-returned.example.test/private',
+ responseBody: 'not-returned',
+ token: 'not-returned',
+ path: privateWindowsPath,
+ environment: 'not-returned',
+ });
+ app.write({ event: 'desktop.app.start_failed', error: 'secret-SENTINEL' });
+ app.close(1, null);
+ },
+ });
+ assert.equal(result.records.length, 20);
+ assert.deepEqual(result.records.at(-1), {
+ event: CONNECT_JOURNEY_FAILURE_EVENT,
+ phase: 'pair',
+ stage: 'JOURNEY_PAIR_REACT_CONNECTED',
+ reason: 'RENDERER_STATE_TIMEOUT',
+ });
+ assert.doesNotMatch(
+ JSON.stringify(result),
+ /secret-SENTINEL|not-returned|private-user|url|responseBody|token|path|environment/u,
+ );
+ });
+
+ test('drops non-allowlisted terminal journey failure fields', async () => {
+ const { result } = await run({
+ onApp: app => {
+ app.write({
+ event: CONNECT_JOURNEY_FAILURE_EVENT,
+ phase: 'hostile-phase',
+ stage: 'HOSTILE_STAGE',
+ reason: 'hostile-reason',
+ error: 'secret-SENTINEL',
+ });
+ app.close(1, null);
+ },
+ });
+ assert.deepEqual(result.records, [{ event: CONNECT_JOURNEY_FAILURE_EVENT }]);
+ assert.doesNotMatch(JSON.stringify(result), /hostile|secret-SENTINEL/u);
+ });
+
+ test('returns only fixed secret-free Local Network Access decision evidence', async () => {
+ const fixed = {
+ event: CONNECT_NETWORK_PERMISSION_EVENT,
+ schemaVersion: 1,
+ permissionCategory: 'loopback-network',
+ decision: 'request',
+ allowed: true,
+ activeBindingCurrent: true,
+ webContentsPresent: true,
+ webContentsEqualsMainWindow: true,
+ mainWindowPresent: true,
+ isMainFrame: true,
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityValid: true,
+ requestingOriginAuthorityEqual: true,
+ };
+ const { result } = await run({
+ onApp: app => {
+ app.write({ ...fixed, url: 'not-returned' });
+ app.write({ ...fixed, permissionCategory: 'notifications', requestingUrl: 'not-returned' });
+ app.close(0, null);
+ },
+ });
+ assert.deepEqual(result.records, [
+ fixed,
+ { event: CONNECT_NETWORK_PERMISSION_EVENT },
+ ]);
+ assert.doesNotMatch(JSON.stringify(result), /not-returned|"url":|"requestingUrl":/u);
+ });
+
+ test('fails closed when an otherwise allowlisted journey stage contains a secret', async () => {
+ const { result } = await run({
+ onApp: app => {
+ app.write({
+ event: CONNECT_JOURNEY_STAGE_EVENT,
+ code: 'JOURNEY_REPROBE_TRANSPORT',
+ detail: 'secret-SENTINEL',
+ });
+ app.close(0, null);
+ },
+ });
+ assert.equal(result.category, 'output-rejected');
+ assert.deepEqual(result.records, [{
+ event: CONNECT_JOURNEY_STAGE_EVENT,
+ code: 'JOURNEY_REPROBE_TRANSPORT',
+ }]);
+ assert.doesNotMatch(JSON.stringify(result), /SENTINEL|detail/u);
+ });
+
+ test('publishes the sole terminal READY only after each real journey phase', async () => {
+ const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
+ assert.equal((main.match(/'desktop\.renderer\.connect_discovery\.ready'/gu) ?? []).length, 1);
+ const connectBranch = main.slice(
+ main.indexOf('if (connectSmoke) {'),
+ main.indexOf('} else if (transportSmoke)'),
+ );
+ const discovery = connectBranch.indexOf('await runPackagedConnectDiscoverySmoke');
+ const journey = connectBranch.indexOf('await runPackagedConnectJourneySmoke');
+ const ready = connectBranch.indexOf('await publishPackagedConnectReady');
+ assert.ok(discovery >= 0 && discovery < journey && journey < ready);
+
+ const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8');
+ const pair = harness.indexOf("outcome = await runPhase('pair')");
+ const reprobe = harness.indexOf("outcome = await runPhase('reprobe')");
+ const persistedEvidence = harness.indexOf('const applicationRequests = journeyFixture.requests');
+ assert.ok(pair >= 0 && pair < reprobe && reprobe < persistedEvidence);
+
+ const manual = main.indexOf("'JOURNEY_PAIR_MANUAL_FORM'");
+ const browser = main.indexOf("reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL')");
+ const credential = main.indexOf("'JOURNEY_PAIR_CREDENTIAL_COMMITTED'");
+ const reprobeReady = main.indexOf("'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY'");
+ const activation = main.indexOf("'JOURNEY_PAIR_ACTIVATION_COMMITTED'");
+ const publication = main.indexOf("'JOURNEY_PAIR_ACTIVATION_PUBLISHED'");
+ const react = main.indexOf("'JOURNEY_PAIR_REACT_CONNECTED'");
+ assert.ok(manual >= 0 && browser >= 0 && credential >= 0 && reprobeReady >= 0
+ && activation >= 0 && publication >= 0 && react >= 0);
+ assert.match(main, /await stages\.waitFor\('CREDENTIAL_COMMITTED'\)[\s\S]*?await stages\.waitFor\('AUTHENTICATED_REPROBE_READY'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_COMMITTED'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_PUBLISHED'\)[\s\S]*?await stages\.waitFor\('REACT_CONNECTED'\)/u);
+ assert.match(main, /if \(packagedSmokeTest && !transportSmoke && !connectJourney\)/u);
+ assert.match(main, /if \(packagedSmokeTest && !connectJourney\) \{/u);
+ assert.doesNotMatch(main, /JOURNEY_PAIR_RENDERER|JOURNEY_REPROBE_RENDERER/u);
+ });
+
test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => {
const { result, invocations } = await run({
onApp: app => app.write(readyRecord()),
@@ -364,6 +637,33 @@ describe('packaged Connect fixture cleanup', () => {
}
};
+ test('closes the journey fixture once and tolerates only the already-stopped server condition', async () => {
+ let socketCloses = 0;
+ let httpCloses = 0;
+ const close = createIdempotentJourneyFixtureClose({
+ closeSocketServer: async () => { socketCloses += 1; },
+ closeHttpServer: async () => {
+ httpCloses += 1;
+ throw Object.assign(new Error('server already stopped'), { code: 'ERR_SERVER_NOT_RUNNING' });
+ },
+ });
+ const first = close();
+ const second = close();
+ assert.equal(first, second);
+ await Promise.all([first, second, close()]);
+ assert.equal(socketCloses, 1);
+ assert.equal(httpCloses, 1);
+
+ const failure = createIdempotentJourneyFixtureClose({
+ closeSocketServer: async () => undefined,
+ closeHttpServer: async () => {
+ throw Object.assign(new Error('/private/path-SENTINEL'), { code: 'EIO' });
+ },
+ });
+ await assert.rejects(failure(), { code: 'EIO' });
+ assert.equal(failure(), failure());
+ });
+
test('retries a transient Windows EBUSY only inside the authorized fixture', async () => {
let attempts = 0;
const result = await removeAuthorizedConnectFixture({
diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs
new file mode 100644
index 000000000..0e8950ebe
--- /dev/null
+++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { describe, test } from 'node:test';
+
+const workflow = await readFile(
+ new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url),
+ 'utf8',
+);
+
+describe('packaged Connect target-native credential setup', () => {
+ test('Linux retains one isolated unlocked libsecret session and rejects plaintext fallback', async () => {
+ const linux = workflow.slice(
+ workflow.indexOf('- name: Run packaged Linux main-to-renderer discovery'),
+ workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'),
+ );
+ assert.match(linux, /keyring_root="\$\(mktemp -d\)"/u);
+ assert.match(linux, /export XDG_DATA_HOME="\$1"/u);
+ assert.match(linux, /export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="\$1"/u);
+ assert.match(linux, /gnome-keyring-daemon --unlock --components=secrets/u);
+
+ const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
+ assert.match(main, /process\.platform === 'linux' \? 'gnome_libsecret' : 'os-protected'/u);
+ assert.match(main, /security\.backend !== requiredStorageBackend/u);
+ });
+
+ test('Darwin uses only a generated ephemeral default keychain and restores it on exit', () => {
+ const darwin = workflow.slice(
+ workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'),
+ workflow.indexOf('- name: Run packaged Windows main-to-renderer discovery'),
+ );
+ assert.match(darwin, /keychain_root="\$\(mktemp -d\)"/u);
+ assert.match(darwin, /keychain_password="\$\(openssl rand -hex 32\)"/u);
+ assert.match(darwin, /trap cleanup_keychain EXIT/u);
+ assert.match(darwin, /security create-keychain -p "\$keychain_password" "\$keychain_path"/u);
+ assert.match(darwin, /security unlock-keychain -p "\$keychain_password" "\$keychain_path"/u);
+ assert.match(darwin, /security list-keychains -d user -s "\$keychain_path"/u);
+ assert.match(darwin, /security default-keychain -d user -s "\$keychain_path"/u);
+ assert.match(darwin, /safe_storage_secret="\$\(openssl rand -hex 32\)"/u);
+ assert.match(darwin, /security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "\$safe_storage_secret" -A "\$keychain_path"/u);
+ assert.match(darwin, /unset safe_storage_secret\n\s+npm run smoke:connect-package/u);
+ assert.match(darwin, /security list-keychains -d user -s "\$\{original_keychains\[@\]\}"/u);
+ assert.match(darwin, /security delete-keychain "\$keychain_path"/u);
+ assert.doesNotMatch(darwin, /echo[^\n]*safe_storage_secret|printf[^\n]*safe_storage_secret/u);
+ assert.doesNotMatch(darwin, /CERTIFICATE|security import|codesign|notari/iu);
+ });
+});
diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs
index 962d53ab4..c4a5e1832 100644
--- a/apps/desktop/scripts/run-native-durability.mjs
+++ b/apps/desktop/scripts/run-native-durability.mjs
@@ -5,6 +5,7 @@ const EXPECTED = Object.freeze({
'credential-service': 72,
'profile-store': 37,
'pairing-shutdown': 10,
+ 'pairing-browser': 1,
});
const expectedTotal = Object.values(EXPECTED).reduce((total, count) => total + count, 0);
const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli'));
@@ -15,6 +16,7 @@ const child = spawn(process.execPath, [
'src/profile-store.test.ts',
'src/credential-service.test.ts',
'src/pairing-response-lifecycle.test.ts',
+ 'src/credential-service.pairing-browser.test.ts',
], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
env: process.env,
@@ -51,6 +53,7 @@ const executed = {
'credential-service': plannedForSuite('main-process desktop credential service'),
'profile-store': plannedForSuite('desktop profile store'),
'pairing-shutdown': plannedForSuite('desktop pairing service IPC native shutdown lifecycle'),
+ 'pairing-browser': plannedForSuite('DesktopCredentialService pairing browser sink'),
};
const reportedCategory = (category) => {
const match = output.match(new RegExp(
diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs
index a8b8493be..e688f7aba 100644
--- a/apps/desktop/scripts/smoke-packaged-connect.mjs
+++ b/apps/desktop/scripts/smoke-packaged-connect.mjs
@@ -1,15 +1,30 @@
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
+import { once } from 'node:events';
import {
- chmod, lstat, mkdir, mkdtemp, readFile, realpath, writeFile,
+ chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
+import { createServer } from 'node:http';
import { basename, dirname, join, relative, resolve } from 'node:path';
+import { Server as SocketIOServer } from 'socket.io';
import {
+ DESKTOP_RENDERER_ORIGIN,
+ DESKTOP_TRANSPORT_SCOPE_HEADER,
+ DESKTOP_TRANSPORT_SCOPE_QUERY,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+} from '@propr/shared';
+import {
+ createIdempotentJourneyFixtureClose,
preservePrimaryWithCleanup,
removeAuthorizedConnectFixture,
runPackagedConnectLifecycle,
} from './packaged-connect-lifecycle.mjs';
+import {
+ createPackagedConnectLaunchArguments,
+ spawnPackagedConnectBinary,
+} from './packaged-connect-launch.mjs';
import {
canonicalizeWindowsFixtureEntry,
encodedWindowsFixtureAcl,
@@ -67,6 +82,254 @@ let packagedConnectPhase = 'fixture-setup';
let windowsStagedContract;
let windowsStagedHandoff;
+const createPackagedJourneyFixture = async () => {
+ const pairingId = `dpr_${'P'.repeat(22)}`;
+ const deviceSecret = 'D'.repeat(43);
+ const activationTicket = 'A'.repeat(43);
+ const token = `propr_it_${'T'.repeat(43)}`;
+ const receipt = 'R'.repeat(22);
+ const requests = [];
+ let endpoint;
+ let approved = false;
+ let active = false;
+ let binding;
+ let mode = 'success';
+ const cors = {
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-ProPR-Desktop-Transport-Scope',
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN,
+ 'Access-Control-Allow-Private-Network': 'true',
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/json',
+ };
+ const readJson = request => new Promise((resolveBody, rejectBody) => {
+ const chunks = [];
+ let bytes = 0;
+ request.on('data', chunk => {
+ bytes += chunk.length;
+ if (bytes > 16 * 1024) {
+ rejectBody(new Error('oversized request'));
+ request.destroy();
+ } else chunks.push(chunk);
+ });
+ request.on('end', () => {
+ try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
+ catch (error) { rejectBody(error); }
+ });
+ request.on('error', rejectBody);
+ });
+ const server = createServer(async (request, response) => {
+ const record = {
+ method: request.method,
+ url: request.url,
+ authorization: request.headers.authorization ?? null,
+ origin: request.headers.origin ?? null,
+ transportScope: request.headers[DESKTOP_TRANSPORT_SCOPE_HEADER.toLowerCase()] ?? null,
+ socketIo: false,
+ };
+ requests.push(record);
+ if (request.method === 'OPTIONS') {
+ response.writeHead(204, cors);
+ response.end();
+ return;
+ }
+ try {
+ if (request.method === 'POST' && request.url?.startsWith('/__packaged/control/')) {
+ const requestedMode = request.url.slice('/__packaged/control/'.length);
+ if (!['success', 'malformed', 'oversized', 'expiry', 'cancel'].includes(requestedMode)) {
+ throw new Error('invalid fixture mode');
+ }
+ mode = requestedMode;
+ approved = false;
+ binding = undefined;
+ response.writeHead(204, cors);
+ response.end();
+ return;
+ }
+ if (request.method === 'GET' && request.url === '/__packaged/evidence') {
+ const authenticatedRest = requests.filter(item => item.socketIo === false
+ && item.url === '/api/auth/user'
+ && item.authorization === `Bearer ${token}`
+ && item.transportScope === null);
+ const authenticatedSockets = requests.filter(item => item.socketIo === true
+ && item.authorization === `Bearer ${token}`
+ && item.transportScope !== null
+ && item.socketAuthScope === item.transportScope);
+ response.writeHead(200, cors);
+ response.end(JSON.stringify({
+ authenticatedRest: authenticatedRest.length,
+ authenticatedSockets: authenticatedSockets.length,
+ }));
+ return;
+ }
+ if (request.method === 'GET' && request.url === '/api/desktop/discovery') {
+ response.writeHead(200, cors);
+ if (mode === 'malformed') {
+ response.end('{"product":"ProPR"}');
+ return;
+ }
+ if (mode === 'oversized') {
+ response.end(`{"ignored":"${'x'.repeat(9 * 1024)}"}`);
+ return;
+ }
+ response.end(JSON.stringify({
+ schemaVersion: 1,
+ product: 'ProPR',
+ version: '0.8.15',
+ apiCompatibility: PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ canonicalEndpoint: null,
+ publicInstanceIdentity: identity,
+ desktopAuthentication: {
+ protocolVersion: 2,
+ browserPairing: true,
+ instanceBearerTokens: true,
+ socketIoBearerAuthentication: true,
+ },
+ }));
+ return;
+ }
+ if (request.method === 'POST' && request.url === '/api/desktop/pairings') {
+ binding = await readJson(request);
+ response.writeHead(201, cors);
+ response.end(JSON.stringify({
+ pairingId,
+ deviceSecret,
+ approvalUrl: `${endpoint}/api/desktop/pairings/${pairingId}/browser`,
+ expiresAt: new Date(Date.now() + (mode === 'expiry' ? 200 : 60_000)).toISOString(),
+ interval: 1,
+ }));
+ return;
+ }
+ if (request.method === 'GET' && request.url === `/api/desktop/pairings/${pairingId}/browser`) {
+ approved = true;
+ response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' });
+ response.end('
Desktop approvedApproved
');
+ return;
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/poll`) {
+ const body = await readJson(request);
+ if (body.deviceSecret !== deviceSecret || !approved || !binding) throw new Error('pairing not approved');
+ if (mode === 'cancel' || mode === 'expiry') {
+ response.writeHead(202, cors);
+ response.end('{"status":"pending","interval":1}');
+ return;
+ }
+ response.writeHead(200, cors);
+ response.end(JSON.stringify({
+ status: 'provisional', token, tokenType: 'Bearer', activationTicket,
+ activationExpiresAt: new Date(Date.now() + 60_000).toISOString(),
+ instanceId: binding.instanceId,
+ origin: binding.origin,
+ scope: binding.scope,
+ credentialGeneration: binding.credentialGeneration,
+ }));
+ return;
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/activate`) {
+ const body = await readJson(request);
+ if (body.deviceSecret !== deviceSecret || body.activationTicket !== activationTicket) {
+ throw new Error('activation binding rejected');
+ }
+ active = true;
+ response.writeHead(200, cors);
+ response.end(JSON.stringify({
+ status: 'active', receipt, activatedAt: new Date().toISOString(), expiresAt: null,
+ }));
+ return;
+ }
+ if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') {
+ active = false;
+ response.writeHead(204, cors);
+ response.end();
+ return;
+ }
+ if (request.method === 'GET' && request.url === '/api/auth/user'
+ && active && record.authorization === `Bearer ${token}`) {
+ response.writeHead(200, cors);
+ response.end(JSON.stringify({
+ id: 'packaged-owner', login: 'packaged-owner', username: 'packaged-owner',
+ displayName: 'Packaged Owner', email: null, avatarUrl: null,
+ role: 'admin', permissions: [], authorizationSource: 'bootstrap',
+ }));
+ return;
+ }
+ if (request.method === 'GET' && record.authorization === `Bearer ${token}`) {
+ response.writeHead(200, cors);
+ response.end('{}');
+ return;
+ }
+ } catch {
+ response.writeHead(400, cors);
+ response.end('{"code":"INVALID_SMOKE_REQUEST"}');
+ return;
+ }
+ response.writeHead(401, cors);
+ response.end('{"code":"INVALID_INSTANCE_TOKEN"}');
+ });
+ const io = new SocketIOServer(server, {
+ path: '/socket.io/',
+ transports: ['websocket'],
+ cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false },
+ });
+ io.of('/').use((socket, next) => {
+ const scopes = new URL(socket.handshake.url, 'http://fixture.invalid')
+ .searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
+ requests.push({
+ method: 'SOCKET.IO',
+ url: socket.handshake.url,
+ authorization: socket.handshake.headers.authorization ?? null,
+ origin: socket.handshake.headers.origin ?? null,
+ transportScope: scopes[0] ?? null,
+ socketAuthScope: socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] ?? null,
+ socketIo: true,
+ });
+ if (!active || socket.handshake.headers.authorization !== `Bearer ${token}`
+ || scopes.length !== 1 || socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] !== scopes[0]) {
+ const error = new Error('INVALID_INSTANCE_TOKEN');
+ error.data = { code: 'INVALID_INSTANCE_TOKEN' };
+ next(error);
+ return;
+ }
+ next();
+ });
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ const address = server.address();
+ if (!address || typeof address === 'string') throw new Error('Packaged journey fixture did not bind');
+ endpoint = `http://127.0.0.1:${address.port}`;
+ const close = createIdempotentJourneyFixtureClose({
+ closeSocketServer: () => io.close(),
+ closeHttpServer: () => new Promise((resolveClose, rejectClose) => {
+ server.close(error => error ? rejectClose(error) : resolveClose());
+ }),
+ });
+ return {
+ endpoint,
+ requests,
+ secrets: [deviceSecret, activationTicket, token],
+ close,
+ };
+};
+
+const directoryContainsPlaintext = async (root, needles) => {
+ const visit = async path => {
+ const entries = await readdir(path, { withFileTypes: true });
+ for (const entry of entries) {
+ const child = join(path, entry.name);
+ if (entry.isDirectory()) {
+ if (await visit(child)) return true;
+ } else if (entry.isFile()) {
+ const contents = await readFile(child);
+ if (needles.some(needle => contents.includes(Buffer.from(needle)))) return true;
+ }
+ }
+ return false;
+ };
+ return visit(root);
+};
+
if (process.platform === 'win32') {
try {
packagedConnectPhase = 'staged-contract';
@@ -219,6 +482,7 @@ const protectWindowsEntries = entries => {
let canonicalTemp;
let fixture;
let generatedFixtureLeaf;
+let journeyFixture;
let outcome = { ok: false, category: 'fixture-setup', capture: 'complete', records: [] };
let failurePhase = 'fixture-setup';
try {
@@ -263,11 +527,13 @@ try {
|| relative(canonicalTemp, configRoot) !== join(generatedFixtureLeaf, 'config')) {
throw new Error('Connect smoke fixture escaped its fixed root');
}
+ if (process.platform !== 'win32') journeyFixture = await createPackagedJourneyFixture();
failurePhase = 'package-validation';
await assertPackageAuthority();
const treeKillerPath = await windowsTreeKiller();
const sensitiveNeedles = [
...secrets, fixture, configRoot, stackRoot, identity,
+ ...(journeyFixture?.secrets ?? []),
...packagedConnectArtifactSensitiveNeedles({
platform: process.platform,
artifactRoot,
@@ -284,39 +550,108 @@ try {
PROPR_CONNECTOR_TOKEN: secrets[1],
PROPR_RELAY_TOKEN: secrets[2],
GITHUB_TOKEN: secrets[3],
+ ...(journeyFixture ? {
+ PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT: journeyFixture.endpoint,
+ } : {}),
};
delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_PARENT;
delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_LEAF;
+ const launchArguments = createPackagedConnectLaunchArguments({
+ platform: process.platform,
+ userDataPath,
+ });
const spawnLifecycleProcess = (executable, args, options) => {
if (executable !== binaryPath) return spawn(executable, args, options);
- const child = spawn(binaryPath, ['--disable-gpu', `--user-data-dir=${userDataPath}`], {
- ...options,
- env: childEnvironment,
+ const child = spawnPackagedConnectBinary({
+ binaryPath,
+ launchArguments: args,
+ options: {
+ ...options,
+ env: options.env,
+ },
+ spawn,
});
return child;
};
failurePhase = 'lifecycle-internal';
- outcome = await runPackagedConnectLifecycle({
- binaryPath,
- args: ['--disable-gpu', `--user-data-dir=${userDataPath}`],
- platform: process.platform,
- arch: process.arch,
- authorityMechanism: authorityMechanism(),
- sensitiveNeedles,
- treeKillerPath,
- env: childEnvironment,
- spawn: spawnLifecycleProcess,
- });
+ const runPhase = async phase => await runPackagedConnectLifecycle({
+ binaryPath,
+ args: launchArguments,
+ platform: process.platform,
+ arch: process.arch,
+ authorityMechanism: authorityMechanism(),
+ sensitiveNeedles,
+ treeKillerPath,
+ env: {
+ ...childEnvironment,
+ ...(journeyFixture ? { PROPR_DESKTOP_CONNECT_JOURNEY_PHASE: phase } : {}),
+ },
+ spawn: spawnLifecycleProcess,
+ });
+ outcome = await runPhase('pair');
+ if (outcome.ok && journeyFixture) {
+ outcome = await runPhase('reprobe');
+ if (outcome.ok) {
+ const applicationRequests = journeyFixture.requests.filter(request => request.method !== 'OPTIONS');
+ const discoveries = applicationRequests.filter(request => request.url === '/api/desktop/discovery');
+ const bootstrap = applicationRequests.filter(request =>
+ request.url === '/api/desktop/pairings'
+ || /^\/api\/desktop\/pairings\/[^/]+\/(?:poll|activate)$/u.test(request.url ?? '')
+ || /\/browser$/u.test(request.url ?? ''));
+ const pairingStarts = bootstrap.filter(request => request.url === '/api/desktop/pairings');
+ const pairingBrowsers = bootstrap.filter(request => /\/browser$/u.test(request.url ?? ''));
+ const pairingPolls = bootstrap.filter(request => /\/poll$/u.test(request.url ?? ''));
+ const pairingActivations = bootstrap.filter(request => /\/activate$/u.test(request.url ?? ''));
+ const authenticatedRest = applicationRequests.filter(request =>
+ request.socketIo === false
+ && request.url === '/api/auth/user'
+ && request.authorization === `Bearer ${journeyFixture.secrets[2]}`);
+ const authenticatedSockets = applicationRequests.filter(request =>
+ request.socketIo === true
+ && request.authorization === `Bearer ${journeyFixture.secrets[2]}`
+ && request.transportScope !== null
+ && request.socketAuthScope === request.transportScope);
+ const socketScopes = new Set(authenticatedSockets.map(request =>
+ new URL(request.url, 'http://fixture.invalid').searchParams.get(DESKTOP_TRANSPORT_SCOPE_QUERY)));
+ const restScopes = new Set(authenticatedRest.map(request => request.transportScope));
+ const firstBearer = applicationRequests.findIndex(request => request.authorization !== null);
+ const firstIdentity = applicationRequests.findIndex(request => request.url === '/api/desktop/discovery');
+ const plaintextPersisted = await directoryContainsPlaintext(userDataPath, journeyFixture.secrets);
+ if (discoveries.length !== 8
+ || discoveries.some(request => request.authorization !== null)
+ || pairingStarts.length !== 3
+ || pairingBrowsers.length !== 3
+ || pairingPolls.length !== 1
+ || pairingActivations.length !== 1
+ || bootstrap.some(request => request.authorization !== null)
+ || authenticatedRest.length < 2
+ || authenticatedSockets.length < 2
+ || restScopes.size !== 1
+ || !restScopes.has(null)
+ || socketScopes.has(null)
+ || socketScopes.size < 2
+ || plaintextPersisted
+ || firstIdentity < 0
+ || firstBearer <= firstIdentity) {
+ outcome = { ok: false, category: 'journey-evidence', capture: 'complete', records: [] };
+ }
+ }
+ }
} catch {
outcome = { ok: false, category: failurePhase, capture: 'complete', records: [] };
} finally {
let cleanup = { ok: true };
+ if (journeyFixture) {
+ try { await journeyFixture.close(); }
+ catch { cleanup = { ok: false, category: 'fixture-cleanup-failed' }; }
+ }
if (fixture && canonicalTemp && generatedFixtureLeaf) {
- cleanup = await removeAuthorizedConnectFixture({
+ const directoryCleanup = await removeAuthorizedConnectFixture({
fixture,
canonicalTemporaryParent: canonicalTemp,
generatedLeaf: generatedFixtureLeaf,
});
+ if (!directoryCleanup.ok) cleanup = directoryCleanup;
}
if (!cleanup.ok) {
outcome = preservePrimaryWithCleanup(outcome, cleanup);
diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs
index 862c7fa79..9baa63f5b 100644
--- a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs
+++ b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs
@@ -1262,7 +1262,7 @@ test('the workflow stages before alternate credentials and the harness preflight
);
const preflight = harness.indexOf('const staged = await validateWindowsStagedPackage');
- const spawn = harness.indexOf("const child = spawn(binaryPath, ['--disable-gpu'");
+ const spawn = harness.indexOf('const child = spawnPackagedConnectBinary');
assert.ok(preflight >= 0 && preflight < spawn, 'ordinary-user package preflight must complete before spawn');
assert.equal((harness.match(/await validateWindowsStagedPackage\(/gu) ?? []).length, 1);
assert.equal((harness.match(/await runPackagedConnectLifecycle\(/gu) ?? []).length, 1);
diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts
index 1177d5915..e732d32cc 100644
--- a/apps/desktop/src/credential-service.pairing-browser.test.ts
+++ b/apps/desktop/src/credential-service.pairing-browser.test.ts
@@ -3,7 +3,11 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
-import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared';
+import {
+ DESKTOP_TRANSPORT_SCOPE_HEADER,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+} from '@propr/shared';
import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service';
import { openApprovedDesktopPairingUrl } from './pairing-browser';
import { ProfileStore, type EncryptionProvider } from './profile-store';
@@ -12,6 +16,7 @@ const pairingId = `dpr_${'A'.repeat(22)}`;
const pairingNow = Date.parse('2026-01-01T00:00:00.000Z');
const origin = 'https://api.example.test';
const approvalUrl = `${origin}/api/desktop/pairings/${pairingId}/browser`;
+const instanceToken = `propr_it_${'T'.repeat(43)}`;
const temporaryDirectories: string[] = [];
const services: DesktopCredentialService[] = [];
@@ -26,8 +31,30 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string
status, headers: { 'Content-Type': 'application/json' },
});
+const discovery = {
+ schemaVersion: 1 as const,
+ product: 'ProPR',
+ version: '0.8.15',
+ apiCompatibility: PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ canonicalEndpoint: null,
+ publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000',
+ desktopAuthentication: {
+ protocolVersion: 2 as const,
+ browserPairing: true,
+ instanceBearerTokens: true,
+ socketIoBearerAuthentication: true,
+ },
+};
+
+interface PairingProofOptions {
+ beforeProvisional?(): void;
+ onRequest?(request: { url: string; authorization: string | null }): void;
+}
+
const createService = async (
openPairingBrowser: (request: DesktopPairingBrowserRequest) => Promise,
+ proof: PairingProofOptions = {},
): Promise => {
const directory = await mkdtemp(join(tmpdir(), 'propr-pairing-sink-'));
temporaryDirectories.push(directory);
@@ -39,21 +66,11 @@ const createService = async (
openPairingBrowser,
fetch: async (input, init) => {
const url = input.toString();
- if (url === `${origin}/api/desktop/discovery`) return json({
- schemaVersion: 1,
- product: 'ProPR',
- version: '0.8.15',
- apiCompatibility: PROPR_API_COMPATIBILITY,
- uiCompatibility: PROPR_UI_COMPATIBILITY,
- canonicalEndpoint: null,
- publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000',
- desktopAuthentication: {
- protocolVersion: 2,
- browserPairing: true,
- instanceBearerTokens: true,
- socketIoBearerAuthentication: true,
- },
+ proof.onRequest?.({
+ url,
+ authorization: new Headers(init?.headers).get('Authorization'),
});
+ if (url === `${origin}/api/desktop/discovery`) return json(discovery);
if (url === `${origin}/api/desktop/pairings`) {
const request = JSON.parse(String(init?.body)) as Record;
binding = {
@@ -67,15 +84,22 @@ const createService = async (
expiresAt: new Date(pairingNow + 10_000).toISOString(), interval: 1,
}, 201);
}
- if (url.endsWith('/poll')) return json({
- status: 'provisional', token: `propr_it_${'T'.repeat(43)}`, tokenType: 'Bearer',
- activationTicket: 'K'.repeat(43),
- activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding,
- });
+ if (url.endsWith('/poll')) {
+ proof.beforeProvisional?.();
+ return json({
+ status: 'provisional', token: instanceToken, tokenType: 'Bearer',
+ activationTicket: 'K'.repeat(43),
+ activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding,
+ });
+ }
if (url.endsWith('/activate')) return json({
status: 'active', receipt: 'R'.repeat(22),
activatedAt: '2026-01-01T00:00:01.000Z', expiresAt: null,
});
+ if (url === `${origin}/api/auth/user`) {
+ assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${instanceToken}`);
+ return json({ username: 'remote-owner' });
+ }
throw new Error('Unexpected pairing request');
},
});
@@ -89,27 +113,62 @@ afterEach(async () => {
});
describe('DesktopCredentialService pairing browser sink', () => {
- it('binds the API base, pairing id, and response URL through the final shell validator', async () => {
+ it('pairs through the browser journey and rejects a response URL replacement', async () => {
const opened: string[] = [];
+ const requests: Array<{ url: string; authorization: string | null }> = [];
+ let browserApproved = false;
const service = await createService(request => openApprovedDesktopPairingUrl(request, {
- openExternal: async url => { opened.push(url); },
- }));
+ openExternal: async url => {
+ opened.push(url);
+ // Models the explicit approval click in the independently authenticated
+ // system browser. The polling fixture refuses to issue a provisional
+ // credential until this manual browser step has completed.
+ browserApproved = true;
+ },
+ }), {
+ beforeProvisional: () => assert.equal(browserApproved, true),
+ onRequest: request => requests.push(request),
+ });
+
+ const profile = { id: 'profile-a', label: 'Remote ProPR', apiBaseUrl: origin };
+ const initialProbe = await service.probe(profile);
+ assert.equal(initialProbe.status, 'authentication-required');
+ const paired = await service.pair(profile);
+ const probed = await service.probe(profile);
+ assert.equal(probed.status, 'ready');
+ if (probed.status !== 'ready') return;
+ const activated = await service.activate(probed.activationTicket);
- assert.deepEqual(await service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), { paired: true });
+ assert.deepEqual(paired, { paired: true });
assert.deepEqual(opened, [approvalUrl]);
- });
+ assert.deepEqual(requests.map(request => request.url), [
+ `${origin}/api/desktop/discovery`,
+ `${origin}/api/desktop/discovery`,
+ `${origin}/api/desktop/pairings`,
+ `${origin}/api/desktop/pairings/${pairingId}/poll`,
+ `${origin}/api/desktop/pairings/${pairingId}/activate`,
+ `${origin}/api/desktop/discovery`,
+ `${origin}/api/auth/user`,
+ ]);
+ assert.deepEqual(requests.map(request => request.authorization), [
+ null, null, null, null, null, null, `Bearer ${instanceToken}`,
+ ]);
+ assert.deepEqual(service.prepareRequest(
+ `${origin}/api/tasks`,
+ { [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope },
+ ).requestHeaders, { Authorization: `Bearer ${instanceToken}` });
+ assert.equal(JSON.stringify([initialProbe, paired, probed, activated, opened]).includes(instanceToken), false);
- it('rejects a URL replaced after the credential service receives the API response', async () => {
- const opened: string[] = [];
- const service = await createService(request => openApprovedDesktopPairingUrl({
+ const replacedOpened: string[] = [];
+ const replacedService = await createService(request => openApprovedDesktopPairingUrl({
...request,
approvalUrl: `${origin}/api/desktop/pairings/dpr_${'B'.repeat(22)}/browser`,
- }, { openExternal: async url => { opened.push(url); } }));
+ }, { openExternal: async url => { replacedOpened.push(url); } }));
await assert.rejects(
- service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }),
+ replacedService.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }),
/Desktop pairing browser request was rejected/,
);
- assert.deepEqual(opened, []);
+ assert.deepEqual(replacedOpened, []);
});
});
diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts
index 1d552d944..39b1b00e3 100644
--- a/apps/desktop/src/credential-service.test.ts
+++ b/apps/desktop/src/credential-service.test.ts
@@ -193,7 +193,7 @@ describe('main-process desktop credential service', () => {
assert.equal(await store.readCredential(profile.id), null);
});
- it('durably rejects malformed relaunch discovery without sending the stored bearer', async () => {
+ it('fails malformed identity closed and classifies legacy public-discovery 401 safely', async () => {
const store = await createStore();
const profile = await store.save({ id: 'profile-malformed', label: 'A', apiBaseUrl: 'https://a.example.test' });
await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A'));
@@ -215,6 +215,71 @@ describe('main-process desktop credential service', () => {
assert.equal(result.status, 'authentication-required');
assert.equal(authorizations.some(Boolean), false);
assert.equal(await store.readCredential(profile.id), null);
+
+ const legacyStore = await createStore();
+ const requests: Array<{ url: string; authorization: string | null }> = [];
+ const legacyService = new DesktopCredentialService({
+ profiles: legacyStore,
+ clientName: 'Legacy remote test',
+ openPairingBrowser: async () => undefined,
+ fetch: async (input, init) => {
+ requests.push({
+ url: input.toString(),
+ authorization: new Headers(init?.headers).get('Authorization'),
+ });
+ return json({ error: 'Unauthorized' }, 401);
+ },
+ });
+ credentialServices.push(legacyService);
+
+ const legacyResult = await legacyService.probe({
+ id: 'legacy-remote',
+ label: 'Legacy remote',
+ apiBaseUrl: 'https://legacy.example.test',
+ });
+
+ assert.deepEqual(legacyResult, {
+ status: 'incompatible',
+ message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.',
+ });
+ assert.deepEqual(requests, [{
+ url: 'https://legacy.example.test/api/desktop/discovery',
+ authorization: null,
+ }]);
+ assert.doesNotMatch(JSON.stringify(legacyResult), /Unauthorized|AUTHENTICATION_REQUIRED/);
+
+ const rejectedLegacyBodies = [
+ '{"error":"Unauthorized","policy":"private policy detail"}',
+ '{"error":"Unauthorized","error":"Unauthorized"}',
+ '{"code":"AUTHENTICATION_REQUIRED"}',
+ ];
+ for (const [index, body] of rejectedLegacyBodies.entries()) {
+ const adversarialStore = await createStore();
+ let adversarialRequests = 0;
+ const adversarialService = new DesktopCredentialService({
+ profiles: adversarialStore,
+ clientName: 'Adversarial legacy remote test',
+ openPairingBrowser: async () => undefined,
+ fetch: async (_input, init) => {
+ adversarialRequests += 1;
+ assert.equal(new Headers(init?.headers).get('Authorization'), null);
+ return new Response(body, {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ });
+ },
+ });
+ credentialServices.push(adversarialService);
+
+ const rejected = await adversarialService.probe({
+ id: `rejected-legacy-${index}`,
+ label: 'Rejected legacy remote',
+ apiBaseUrl: `https://rejected-${index}.example.test`,
+ });
+
+ assert.equal(rejected.status, 'authentication-required');
+ assert.equal(adversarialRequests, 1);
+ assert.doesNotMatch(JSON.stringify(rejected), /private policy detail|Unauthorized|AUTHENTICATION_REQUIRED/);
+ }
});
it('revalidates an old Socket.IO reconnect and sends zero bearer requests after identity rotation', async () => {
diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts
index 3065173c0..9e6aa97d2 100644
--- a/apps/desktop/src/credential-service.ts
+++ b/apps/desktop/src/credential-service.ts
@@ -1,5 +1,6 @@
import { randomBytes } from 'node:crypto';
import {
+ DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED,
ProprClient,
ProprClientError,
type PairingProtocolRequestOptions,
@@ -723,6 +724,17 @@ export class DesktopCredentialService {
try {
discovery = await discoveryClient.discoverDesktop(8_000, operation.signal);
} catch (error) {
+ // Only the client's typed signal for the exact credential-free public
+ // discovery request is actionable here. Generic HTTP 401s, malformed
+ // identity, redirects, and authenticated operation failures stay strict.
+ if (error instanceof ProprClientError
+ && error.kind === 'invalid_response'
+ && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED) {
+ return {
+ status: 'incompatible',
+ message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.',
+ };
+ }
if (error instanceof ProprClientError && error.kind === 'invalid_response') {
try {
const current = await this.#profiles.readProfileCredential(input.id);
@@ -1039,10 +1051,19 @@ export class DesktopCredentialService {
}
}
+ /** Whether main still owns the complete binding required by renderer transport and LNA. */
+ hasActiveRendererBinding(): boolean {
+ const active = this.#active;
+ return active !== null
+ && this.#generation(active.profileId) === active.profileGeneration
+ && this.#selectionGeneration === active.selectionGeneration
+ && active.connectClaim.isCurrent();
+ }
+
prepareRequest(
url: string,
originalHeaders: RequestHeaders,
- details: { method?: string; resourceType?: string } = {},
+ details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {},
verifiedSocketCredential?: ActiveCredential,
): DesktopRequestDecision {
if (this.#closed) return { cancel: true };
@@ -1085,16 +1106,31 @@ export class DesktopCredentialService {
&& this.#selectionGeneration === active.selectionGeneration
&& active.connectClaim.isCurrent();
const isApiRequest = target?.pathname.startsWith('/api/') === true;
+ const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? [];
+ const isSocketCandidate = target?.pathname === '/socket.io/' || socketScopeValues.length > 0;
const isSocketUpgrade = target?.pathname === '/socket.io/'
&& target.url.searchParams.get('transport') === 'websocket'
&& (details.resourceType === 'webSocket'
|| headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket'));
+ // Session security supplies this ownership bit at the actual WebContents
+ // boundary. A foreign renderer may load only unmarked credentialless
+ // resources; it can never exercise a REST/Socket scope or receive a bearer.
+ if (details.rendererOwned === false) {
+ if (markedRestRequest || isSocketCandidate) return { cancel: true };
+ return { requestHeaders: headers };
+ }
+
+ // Chromium can cache Local Network Access after activation is discarded.
+ // The live main renderer must therefore remain pinned to the exact current
+ // origin even for sanitized traffic that does not carry a transport scope.
+ if (details.rendererOwned === true && target
+ && (!activeIsCurrent || target.origin !== active.origin)) return { cancel: true };
+
if (isSocketUpgrade && target) {
- const queryScopes = target.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
- if (queryScopes.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(queryScopes[0])
+ if (socketScopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0])
|| !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin
- || queryScopes[0] !== active.transportScope) return { cancel: true };
+ || socketScopeValues[0] !== active.transportScope) return { cancel: true };
headers.Authorization = `Bearer ${active.token}`;
return { requestHeaders: headers };
}
@@ -1111,14 +1147,16 @@ export class DesktopCredentialService {
async prepareRequestAsync(
url: string,
originalHeaders: RequestHeaders,
- details: { method?: string; resourceType?: string } = {},
+ details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {},
): Promise {
const target = requestOrigin(url);
const isSocketUpgrade = target?.pathname === '/socket.io/'
&& target.url.searchParams.get('transport') === 'websocket'
&& (details.resourceType === 'webSocket'
|| headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket'));
- if (!isSocketUpgrade) return this.prepareRequest(url, originalHeaders, details);
+ if (!isSocketUpgrade || details.rendererOwned === false) {
+ return this.prepareRequest(url, originalHeaders, details);
+ }
const active = this.#active;
if (!active || target.origin !== active.origin) return this.prepareRequest(url, originalHeaders, details);
try {
diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts
index d16e5a090..e2cda64ff 100644
--- a/apps/desktop/src/ipc.ts
+++ b/apps/desktop/src/ipc.ts
@@ -7,6 +7,16 @@ import type { LocalLifecycleController } from './lifecycle';
import type { ProfileStore } from './profile-store';
import { isSafeExternalUrl, isTrustedRendererUrl } from './security';
import { IPC_CHANNELS } from './shared/contract';
+import type { DesktopAcceptanceJourneyStage } from './shared/contract';
+
+export type DesktopAcceptanceOperation = 'PROFILE_SAVE' | 'PAIR' | 'PROBE' | 'ACTIVATE';
+export type DesktopAcceptanceOperationStatus =
+ | 'COMPLETED'
+ | 'READY'
+ | 'AUTHENTICATION_REQUIRED'
+ | 'INCOMPATIBLE'
+ | 'OFFLINE'
+ | 'REJECTED';
interface RegisterIpcOptions {
app: App;
@@ -22,6 +32,13 @@ interface RegisterIpcOptions {
openExternal(url: string): Promise;
/** @internal Deterministic admitted-work accounting for lifecycle proof. */
observeInvocation?(phase: 'entry' | 'exit', channel: string): void;
+ /** @internal Fixed, secret-free packaged Connect acceptance evidence. */
+ reportAcceptanceJourneyStage?(stage: DesktopAcceptanceJourneyStage): void;
+ /** @internal Fixed, secret-free packaged Connect operation evidence. */
+ reportAcceptanceOperation?(
+ operation: DesktopAcceptanceOperation,
+ status: DesktopAcceptanceOperationStatus,
+ ): void;
}
type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown;
@@ -33,6 +50,32 @@ export interface RegisteredIpcHandlers {
}
const closingError = (): Error => new Error('DESKTOP_CLOSING');
+const acceptanceStages = new Set([
+ 'AUTHENTICATION_REQUIRED',
+ 'CREDENTIAL_COMMITTED',
+ 'AUTHENTICATED_REPROBE_READY',
+ 'ACTIVATION_COMMITTED',
+ 'ACTIVATION_PUBLISHED',
+ 'REACT_CONNECTED',
+]);
+const acceptanceOperations = new Map([
+ [IPC_CHANNELS.profilesSave, 'PROFILE_SAVE'],
+ [IPC_CHANNELS.authenticationPair, 'PAIR'],
+ [IPC_CHANNELS.connectionProbe, 'PROBE'],
+ [IPC_CHANNELS.connectionActivate, 'ACTIVATE'],
+]);
+
+const acceptanceStatus = (result: unknown): DesktopAcceptanceOperationStatus => {
+ if (!result || typeof result !== 'object' || Array.isArray(result) || !('status' in result)) {
+ return 'COMPLETED';
+ }
+ const status = (result as { status?: unknown }).status;
+ if (status === 'ready') return 'READY';
+ if (status === 'authentication-required') return 'AUTHENTICATION_REQUIRED';
+ if (status === 'incompatible') return 'INCOMPATIBLE';
+ if (status === 'offline') return 'OFFLINE';
+ return 'COMPLETED';
+};
export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcHandlers => {
const channels = new Set();
@@ -54,8 +97,13 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH
const invocation = Promise.resolve().then(() => handler(event, ...args));
active.add(invocation);
try {
- return await invocation;
+ const result = await invocation;
+ const operation = acceptanceOperations.get(channel);
+ if (operation) options.reportAcceptanceOperation?.(operation, acceptanceStatus(result));
+ return result;
} catch (error) {
+ const operation = acceptanceOperations.get(channel);
+ if (operation) options.reportAcceptanceOperation?.(operation, 'REJECTED');
options.logger.log('error', 'desktop.ipc.failed', { channel, code: 'IPC_OPERATION_FAILED' });
throw new Error('Desktop operation failed [IPC_OPERATION_FAILED]');
} finally {
@@ -136,6 +184,12 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH
if (args.length) throw new Error('Invalid Connect rediscovery request');
return options.connectDiscovery.rediscover(profileId);
});
+ if (options.reportAcceptanceJourneyStage) {
+ handle(IPC_CHANNELS.acceptanceJourneyStage, (_event, stage, ...args) => {
+ if (args.length || !acceptanceStages.has(stage)) throw new Error('Invalid acceptance journey stage');
+ options.reportAcceptanceJourneyStage!(stage);
+ });
+ }
handle(IPC_CHANNELS.lifecycleStatus, () => options.lifecycle.status());
handle(IPC_CHANNELS.lifecycleStart, () => options.lifecycle.start());
handle(IPC_CHANNELS.lifecycleStop, () => options.lifecycle.stop());
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 7dd2aa65d..8ce0d7a82 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -17,12 +17,21 @@ import {
import { DesktopConnectDiscoveryService } from './connect-discovery';
import { DeepLinkDelivery } from './deep-link-delivery';
import { clearDesktopInstanceCookies } from './desktop-session';
-import { DesktopCredentialService } from './credential-service';
-import { registerIpcHandlers } from './ipc';
+import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service';
+import {
+ registerIpcHandlers,
+ type DesktopAcceptanceOperation,
+ type DesktopAcceptanceOperationStatus,
+} from './ipc';
import { LocalLifecycleController } from './lifecycle';
import { createDesktopLogger, type DesktopLogger } from './logger';
import { ProfileStore, type EncryptionProvider } from './profile-store';
import { openApprovedDesktopPairingUrl } from './pairing-browser';
+import {
+ clearPackagedApprovalStorage,
+ createPackagedApprovalNavigation,
+ packagedApprovalPartition,
+} from './packaged-approval-session';
import { createDesktopShutdownCoordinator } from './shutdown';
import {
deepLinkFromArguments,
@@ -33,10 +42,19 @@ import {
rendererContentSecurityPolicy,
validatedDevServerUrl,
} from './security';
-import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract';
+import {
+ DESKTOP_PROTOCOL,
+ IPC_CHANNELS,
+ type DesktopAcceptanceJourneyStage,
+} from './shared/contract';
import { checkForSignedUpdates } from './signed-updates';
import { authorizePackagedSmokeTest } from './smoke-test-authorization';
import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence';
+import {
+ configureDesktopSessionSecurity,
+ type DesktopNetworkPermissionEvidence,
+ type DesktopRendererOwnershipEvidence,
+} from './session-security';
import {
createBrowserWindowOptions,
MINIMUM_BROWSER_WINDOW_SIZE,
@@ -50,6 +68,49 @@ const PACKAGED_RENDERER_SCHEME = 'propr-app';
const PACKAGED_RENDERER_HOST = 'renderer';
const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready';
const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready';
+const PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone';
+const PACKAGED_CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage';
+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';
+type PackagedConnectJourneyStage =
+ | 'JOURNEY_DISCOVERY_RENDERER'
+ | 'JOURNEY_DISCOVERY_VALIDATED'
+ | 'JOURNEY_STORAGE_BACKEND'
+ | 'JOURNEY_NEGATIVE_MALFORMED'
+ | 'JOURNEY_NEGATIVE_OVERSIZED'
+ | 'JOURNEY_NEGATIVE_EXPIRY'
+ | 'JOURNEY_NEGATIVE_CANCEL'
+ | 'JOURNEY_NEGATIVE_STATE'
+ | 'JOURNEY_PAIR_MANUAL_FORM'
+ | 'JOURNEY_PAIR_BROWSER_APPROVAL'
+ | 'JOURNEY_PAIR_ACTIVATION_DASHBOARD'
+ | 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED'
+ | 'JOURNEY_PAIR_CREDENTIAL_COMMITTED'
+ | 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY'
+ | 'JOURNEY_PAIR_ACTIVATION_COMMITTED'
+ | 'JOURNEY_PAIR_ACTIVATION_PUBLISHED'
+ | 'JOURNEY_PAIR_REACT_CONNECTED'
+ | 'JOURNEY_PAIR_TRANSPORT'
+ | 'JOURNEY_PAIR_COMPLETE'
+ | 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD'
+ | 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY'
+ | 'JOURNEY_REPROBE_ACTIVATION_COMMITTED'
+ | 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED'
+ | 'JOURNEY_REPROBE_REACT_CONNECTED'
+ | 'JOURNEY_REPROBE_TRANSPORT'
+ | 'JOURNEY_REPROBE_COMPLETE';
+type PackagedConnectJourneyFailureReason =
+ | 'APPROVAL_REJECTED'
+ | 'JOURNEY_FAILED'
+ | 'RENDERER_STAGE_TIMEOUT'
+ | 'RENDERER_STATE_TIMEOUT'
+ | 'TRANSPORT_EVIDENCE_TIMEOUT';
+interface PackagedConnectJourneyDiagnosticState {
+ phase: 'pair' | 'reprobe';
+ stage: PackagedConnectJourneyStage | 'JOURNEY_NOT_STARTED';
+}
+let packagedConnectJourneyDiagnosticState: PackagedConnectJourneyDiagnosticState | null = null;
const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
let packagedSmokeUserDataDirectory: string | null = null;
@@ -93,10 +154,13 @@ interface PackagedTransportSmoke {
shutdownMode: 'success' | 'retry' | 'forced-timeout';
}
let activePackagedTransportSmoke: PackagedTransportSmoke | null = null;
+let activePackagedConnectJourney = false;
interface PackagedConnectSmoke {
configRoot: string;
fetch: typeof globalThis.fetch;
+ journeyEndpoint?: string;
+ journeyPhase?: 'pair' | 'reprobe';
}
const packagedConnectSmoke = (): PackagedConnectSmoke | null => {
@@ -109,6 +173,21 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => {
if (!contained || contained.startsWith('..') || isAbsolute(contained)) {
throw new Error('Packaged Connect smoke config root is outside the temporary directory');
}
+ const suppliedJourneyEndpoint = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT;
+ const suppliedJourneyPhase = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE;
+ let journeyEndpoint: string | undefined;
+ let journeyPhase: 'pair' | 'reprobe' | undefined;
+ if (suppliedJourneyEndpoint !== undefined || suppliedJourneyPhase !== undefined) {
+ const normalized = normalizeApiBaseUrl(suppliedJourneyEndpoint ?? '');
+ if (!normalized) throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture');
+ const parsed = new URL(normalized);
+ if (process.platform === 'win32' || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
+ || (suppliedJourneyPhase !== 'pair' && suppliedJourneyPhase !== 'reprobe')) {
+ throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture');
+ }
+ journeyEndpoint = normalized;
+ journeyPhase = suppliedJourneyPhase;
+ }
const endpoint = 'https://t-packaged123.propr.dev';
const publicInstanceIdentity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const fetch: typeof globalThis.fetch = async input => {
@@ -131,7 +210,7 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => {
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
- return { configRoot, fetch };
+ return { configRoot, fetch, journeyEndpoint, journeyPhase };
};
const packagedTransportSmoke = (): PackagedTransportSmoke | null => {
@@ -169,6 +248,80 @@ const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?:
}
};
+const reportPackagedConnectJourneyStage = (code: PackagedConnectJourneyStage): void => {
+ if (packagedConnectJourneyDiagnosticState) packagedConnectJourneyDiagnosticState.stage = code;
+ log('info', PACKAGED_CONNECT_JOURNEY_STAGE_EVENT, { code });
+};
+
+const packagedConnectJourneyFailureReason = (error: unknown): PackagedConnectJourneyFailureReason => {
+ if (!(error instanceof Error)) return 'JOURNEY_FAILED';
+ if (error.message === 'Packaged pairing browser approval was rejected') return 'APPROVAL_REJECTED';
+ if (error.message === 'Packaged Connect journey renderer stage timed out') {
+ return 'RENDERER_STAGE_TIMEOUT';
+ }
+ if (error.message === 'Packaged Connect journey renderer state timed out') {
+ return 'RENDERER_STATE_TIMEOUT';
+ }
+ if (error.message === 'Packaged Connect authenticated transport proof timed out') {
+ return 'TRANSPORT_EVIDENCE_TIMEOUT';
+ }
+ return 'JOURNEY_FAILED';
+};
+
+interface PackagedJourneyStageTracker {
+ record(stage: DesktopAcceptanceJourneyStage): void;
+ waitFor(stage: DesktopAcceptanceJourneyStage): Promise;
+}
+
+const createPackagedJourneyStageTracker = (
+ phase: 'pair' | 'reprobe',
+): PackagedJourneyStageTracker => {
+ const seen = new Set();
+ const waiters = new Map void>>();
+ const stageCodes: Partial> = phase === 'pair'
+ ? {
+ AUTHENTICATION_REQUIRED: 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED',
+ CREDENTIAL_COMMITTED: 'JOURNEY_PAIR_CREDENTIAL_COMMITTED',
+ AUTHENTICATED_REPROBE_READY: 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY',
+ ACTIVATION_COMMITTED: 'JOURNEY_PAIR_ACTIVATION_COMMITTED',
+ ACTIVATION_PUBLISHED: 'JOURNEY_PAIR_ACTIVATION_PUBLISHED',
+ REACT_CONNECTED: 'JOURNEY_PAIR_REACT_CONNECTED',
+ }
+ : {
+ AUTHENTICATED_REPROBE_READY: 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY',
+ ACTIVATION_COMMITTED: 'JOURNEY_REPROBE_ACTIVATION_COMMITTED',
+ ACTIVATION_PUBLISHED: 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED',
+ REACT_CONNECTED: 'JOURNEY_REPROBE_REACT_CONNECTED',
+ };
+ return {
+ record(stage) {
+ const code = stageCodes[stage];
+ if (!code) throw new Error('Packaged Connect journey reported an invalid phase stage');
+ if (seen.has(stage)) return;
+ seen.add(stage);
+ reportPackagedConnectJourneyStage(code);
+ for (const resolveWaiter of waiters.get(stage) ?? []) resolveWaiter();
+ waiters.delete(stage);
+ },
+ waitFor(stage) {
+ if (seen.has(stage)) return Promise.resolve();
+ return new Promise((resolveStage, rejectStage) => {
+ const timer = setTimeout(() => {
+ waiters.get(stage)?.delete(resolve);
+ rejectStage(new Error('Packaged Connect journey renderer stage timed out'));
+ }, 15_000);
+ const resolve = () => {
+ clearTimeout(timer);
+ resolveStage();
+ };
+ const current = waiters.get(stage) ?? new Set();
+ current.add(resolve);
+ waiters.set(stage, current);
+ });
+ },
+ };
+};
+
process.on('uncaughtExceptionMonitor', () => {
log('error', 'desktop.main_process.uncaught_exception', { code: 'UNCAUGHT_EXCEPTION' });
});
@@ -195,41 +348,6 @@ const deliverDeepLink = (value: string): void => {
deepLinkDelivery.deliver(value);
};
-const configureSessionSecurity = (credentials: DesktopCredentialService): {
- close(): void;
- dispose(): void;
-} => {
- const desktopSession = session.defaultSession;
- desktopSession.setPermissionCheckHandler(() => false);
- desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
- desktopSession.webRequest.onBeforeSendHeaders((details, callback) => {
- void credentials.prepareRequestAsync(details.url, details.requestHeaders, {
- method: details.method,
- resourceType: details.resourceType,
- }).then(callback, () => callback({ cancel: true }));
- });
- desktopSession.webRequest.onHeadersReceived((details, callback) => {
- callback({
- responseHeaders: {
- ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}),
- 'Content-Security-Policy': [rendererContentSecurityPolicy(!app.isPackaged)],
- },
- });
- });
- return {
- close() {
- desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true }));
- desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true }));
- },
- dispose() {
- desktopSession.setPermissionCheckHandler(null);
- desktopSession.setPermissionRequestHandler(null);
- desktopSession.webRequest.onBeforeSendHeaders(null);
- desktopSession.webRequest.onHeadersReceived(null);
- },
- };
-};
-
const configurePackagedRendererProtocol = (): (() => void) => {
protocol.handle(PACKAGED_RENDERER_SCHEME, request => {
const requestUrl = new URL(request.url);
@@ -381,7 +499,12 @@ const inspectPackagedReducedNativeWindow = (): Record => {
}
};
-const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise => {
+const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<{
+ selectedPlatform: string;
+ selectedArch: string;
+ authorityMechanism: string;
+ rendererSchemaValid: true;
+}> => {
const proof = await window.webContents.executeJavaScript(`(async () => {
const bridge = window.proprDesktop;
const metadata = await bridge.app.getMetadata();
@@ -412,7 +535,15 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<
: 'inherited-standard-handle',
rendererSchemaValid: true,
} as const;
- log('info', 'desktop.renderer.connect_discovery.ready', readyFields);
+ log('info', PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT, {
+ code: 'JOURNEY_DISCOVERY_VALIDATED',
+ });
+ return readyFields;
+};
+
+const publishPackagedConnectReady = async (readyFields: Awaited<
+ ReturnType
+>): Promise => {
await new Promise((resolveReady, rejectReady) => {
process.stdout.write(`${JSON.stringify({
timestamp: new Date().toISOString(),
@@ -426,6 +557,223 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<
});
};
+const openPackagedJourneyApproval = async (request: DesktopPairingBrowserRequest): Promise => {
+ reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL');
+ await openApprovedDesktopPairingUrl(request, {
+ openExternal: async url => {
+ const approvalSession = session.fromPartition(
+ packagedApprovalPartition(randomBytes(16).toString('hex')),
+ { cache: false },
+ );
+ let approvalWindow: BrowserWindow | null = null;
+ let navigation: ReturnType | null = null;
+ try {
+ approvalWindow = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ session: approvalSession,
+ webSecurity: true,
+ },
+ });
+ navigation = createPackagedApprovalNavigation({
+ approvalUrl: url,
+ approvalSession,
+ approvalWindow,
+ defaultSession: session.defaultSession,
+ });
+ await navigation.navigate();
+ } finally {
+ if (navigation) {
+ await navigation.cleanup();
+ } else {
+ if (approvalWindow && !approvalWindow.isDestroyed()) approvalWindow.destroy();
+ await clearPackagedApprovalStorage(approvalSession);
+ }
+ }
+ },
+ });
+};
+
+const runPackagedConnectJourneySmoke = async (
+ window: BrowserWindow,
+ profiles: ProfileStore,
+ credentials: DesktopCredentialService,
+ endpoint: string,
+ phase: 'pair' | 'reprobe',
+ stages: PackagedJourneyStageTracker,
+): Promise => {
+ reportPackagedConnectJourneyStage('JOURNEY_STORAGE_BACKEND');
+ const security = profiles.security();
+ const requiredStorageBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected';
+ if (!security.available || security.backend !== requiredStorageBackend) {
+ throw new Error('Packaged Connect journey requires the production OS credential backend');
+ }
+ if (phase === 'pair') {
+ const setMode = async (mode: 'success' | 'malformed' | 'oversized' | 'expiry' | 'cancel') => {
+ const response = await session.defaultSession.fetch(`${endpoint}/__packaged/control/${mode}`, {
+ method: 'POST', redirect: 'manual',
+ });
+ if (response.status !== 204) throw new Error('Packaged Connect fixture control failed');
+ };
+ for (const mode of ['malformed', 'oversized'] as const) {
+ reportPackagedConnectJourneyStage(mode === 'malformed'
+ ? 'JOURNEY_NEGATIVE_MALFORMED'
+ : 'JOURNEY_NEGATIVE_OVERSIZED');
+ await setMode(mode);
+ const result = await credentials.probe({
+ id: `negative-${mode}`,
+ label: `Packaged ${mode}`,
+ apiBaseUrl: endpoint,
+ });
+ if (result.status === 'ready' || result.status === 'incompatible') {
+ throw new Error('Strict packaged discovery accepted invalid identity');
+ }
+ }
+ reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_EXPIRY');
+ await setMode('expiry');
+ await credentials.pair({
+ id: 'negative-expiry', label: 'Packaged expiry', apiBaseUrl: endpoint,
+ }).then(
+ () => { throw new Error('Packaged pairing expiry unexpectedly succeeded'); },
+ error => {
+ if (!(error instanceof Error) || !/expired/i.test(error.message)) {
+ throw new Error('Packaged pairing expiry classification failed');
+ }
+ },
+ );
+ reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_CANCEL');
+ await setMode('cancel');
+ const cancelledPairing = credentials.pair({
+ id: 'negative-cancel', label: 'Packaged cancel', apiBaseUrl: endpoint,
+ });
+ await new Promise(resolve => setTimeout(resolve, 50));
+ credentials.cancelPairing('negative-cancel');
+ await cancelledPairing.then(
+ () => { throw new Error('Packaged pairing cancellation unexpectedly succeeded'); },
+ error => {
+ if (!(error instanceof Error) || !/cancelled/i.test(error.message)) {
+ throw new Error('Packaged pairing cancellation classification failed');
+ }
+ },
+ );
+ reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_STATE');
+ const failedProfiles = await profiles.list();
+ if (failedProfiles.profiles.some(profile => profile.id.startsWith('negative-'))) {
+ throw new Error('Failed packaged pairing left stale profile or credential state');
+ }
+ await setMode('success');
+ }
+ reportPackagedConnectJourneyStage(phase === 'pair'
+ ? 'JOURNEY_PAIR_MANUAL_FORM'
+ : 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD');
+ if (phase === 'pair') {
+ const submitted = await window.webContents.executeJavaScript(`(async () => {
+ const waitFor = async predicate => {
+ const deadline = performance.now() + 15000;
+ do {
+ const value = predicate();
+ if (value) return value;
+ await new Promise(resolve => setTimeout(resolve, 25));
+ } while (performance.now() < deadline);
+ throw new Error('Packaged Connect journey renderer state timed out');
+ };
+ const setInput = (input, value) => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ setter.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ };
+ const chooser = await waitFor(() => document.querySelector('.desktop-welcome-card'));
+ const connect = Array.from(chooser.querySelectorAll('button.desktop-choice-button'))
+ .find(button => button.textContent?.includes('Connect to an existing instance'));
+ if (!(connect instanceof HTMLButtonElement)) return false;
+ connect.click();
+ const form = await waitFor(() => document.querySelector('form.desktop-profile-form'));
+ const inputs = form.querySelectorAll('input');
+ if (inputs.length !== 2) return false;
+ setInput(inputs[0], 'Packaged remote');
+ setInput(inputs[1], ${JSON.stringify(endpoint)});
+ form.requestSubmit();
+ await waitFor(() => Array.from(document.querySelectorAll('.desktop-connection-card button'))
+ .find(button => button.textContent?.includes('Sign in in browser')));
+ return true;
+ })()`);
+ if (submitted !== true) throw new Error('Packaged Connect manual profile submission failed');
+ await stages.waitFor('AUTHENTICATION_REQUIRED');
+ const clicked = await window.webContents.executeJavaScript(`(() => {
+ const authenticate = Array.from(document.querySelectorAll('.desktop-connection-card button'))
+ .find(button => button.textContent?.includes('Sign in in browser'));
+ if (!(authenticate instanceof HTMLButtonElement)) return false;
+ authenticate.click();
+ return true;
+ })()`);
+ if (clicked !== true) throw new Error('Packaged Connect authentication action was missing');
+ await stages.waitFor('CREDENTIAL_COMMITTED');
+ }
+ await stages.waitFor('AUTHENTICATED_REPROBE_READY');
+ await stages.waitFor('ACTIVATION_COMMITTED');
+ await stages.waitFor('ACTIVATION_PUBLISHED');
+ await stages.waitFor('REACT_CONNECTED');
+ const proof = await window.webContents.executeJavaScript(`(async () => {
+ const waitFor = async predicate => {
+ const deadline = performance.now() + 15000;
+ do {
+ const value = predicate();
+ if (value) return value;
+ await new Promise(resolve => setTimeout(resolve, 25));
+ } while (performance.now() < deadline);
+ throw new Error('Packaged Connect journey renderer state timed out');
+ };
+ const dashboard = await waitFor(() => document.querySelector('.desktop-app'));
+ const connection = await waitFor(() => document.querySelector('.desktop-connection-pill.desktop-connection-ready'));
+ await waitFor(() => document.querySelector('.desktop-titlebar'));
+ return {
+ connected: dashboard instanceof HTMLElement && connection instanceof HTMLButtonElement,
+ rendererContractsContainSecret: JSON.stringify([window.proprDesktop, dashboard.dataset]).includes('propr_it_'),
+ title: connection.getAttribute('aria-label'),
+ };
+ })()`);
+ if (proof?.connected !== true || proof?.rendererContractsContainSecret !== false
+ || !proof?.title?.startsWith('Connected: Packaged remote')) {
+ throw new Error('Packaged Connect dashboard did not reach its connected state');
+ }
+ reportPackagedConnectJourneyStage(phase === 'pair'
+ ? 'JOURNEY_PAIR_TRANSPORT'
+ : 'JOURNEY_REPROBE_TRANSPORT');
+ const requiredAuthenticatedRequests = phase === 'pair' ? 1 : 2;
+ const evidenceDeadline = Date.now() + 10_000;
+ let transportEvidence = { authenticatedRest: 0, authenticatedSockets: 0 };
+ do {
+ const response = await session.defaultSession.fetch(`${endpoint}/__packaged/evidence`, {
+ credentials: 'omit',
+ redirect: 'manual',
+ });
+ if (response.status !== 200) throw new Error('Packaged Connect transport evidence was unavailable');
+ const candidate: unknown = await response.json();
+ if (candidate !== null && typeof candidate === 'object') {
+ const record = candidate as Record;
+ if (Number.isInteger(record.authenticatedRest) && Number.isInteger(record.authenticatedSockets)) {
+ transportEvidence = {
+ authenticatedRest: record.authenticatedRest as number,
+ authenticatedSockets: record.authenticatedSockets as number,
+ };
+ }
+ }
+ if (transportEvidence.authenticatedRest >= requiredAuthenticatedRequests
+ && transportEvidence.authenticatedSockets >= requiredAuthenticatedRequests) break;
+ await new Promise(resolve => setTimeout(resolve, 25));
+ } while (Date.now() < evidenceDeadline);
+ if (transportEvidence.authenticatedRest < requiredAuthenticatedRequests
+ || transportEvidence.authenticatedSockets < requiredAuthenticatedRequests) {
+ throw new Error('Packaged Connect authenticated transport proof timed out');
+ }
+ reportPackagedConnectJourneyStage(phase === 'pair'
+ ? 'JOURNEY_PAIR_COMPLETE'
+ : 'JOURNEY_REPROBE_COMPLETE');
+};
+
const runPackagedTransportSmoke = async (
window: BrowserWindow,
profiles: ProfileStore,
@@ -613,6 +961,7 @@ const runPackagedTransportSmoke = async (
const createMainWindow = async (
transportSmoke: PackagedTransportSmoke | null = activePackagedTransportSmoke,
+ connectJourney = activePackagedConnectJourney,
): Promise => {
const workArea = selectInitialWindowWorkArea(screen);
const window = new BrowserWindow(
@@ -687,7 +1036,7 @@ const createMainWindow = async (
log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN });
}
let mvpFlowProof: Record = { connectDiscovery: true };
- if (packagedSmokeTest && !transportSmoke) {
+ 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' });
@@ -739,7 +1088,7 @@ const createMainWindow = async (
throw new Error('Packaged desktop transport smoke did not preserve the MVP bridge boundaries');
}
}
- if (packagedSmokeTest) {
+ if (packagedSmokeTest && !connectJourney) {
log('info', 'desktop.renderer.mvp_flows.ready', mvpFlowProof);
log('info', PACKAGED_LAYOUT_READY_EVENT, { layout: await inspectPackagedLayout(window) });
log('info', PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT, {
@@ -783,7 +1132,14 @@ if (!hasSingleInstanceLock) {
const transportSmoke = packagedTransportSmoke();
activePackagedTransportSmoke = transportSmoke;
const connectSmoke = packagedConnectSmoke();
+ activePackagedConnectJourney = Boolean(connectSmoke?.journeyEndpoint);
+ packagedConnectJourneyDiagnosticState = connectSmoke?.journeyEndpoint && connectSmoke.journeyPhase
+ ? { phase: connectSmoke.journeyPhase, stage: 'JOURNEY_NOT_STARTED' }
+ : null;
if (transportSmoke && connectSmoke) throw new Error('Packaged desktop smoke modes are mutually exclusive');
+ const journeyStages = connectSmoke?.journeyPhase
+ ? createPackagedJourneyStageTracker(connectSmoke.journeyPhase)
+ : null;
const productionEncryption: EncryptionProvider = {
isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
@@ -834,7 +1190,9 @@ if (!hasSingleInstanceLock) {
const credentials = new DesktopCredentialService({
profiles,
fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch,
- openPairingBrowser: request => openApprovedDesktopPairingUrl(request, shell),
+ openPairingBrowser: connectSmoke?.journeyEndpoint
+ ? openPackagedJourneyApproval
+ : request => openApprovedDesktopPairingUrl(request, shell),
clientName: `ProPR Desktop (${process.platform})`,
reportRevocationFailure: diagnostic => {
log('warn', 'desktop.credential_revocation.retry_pending', diagnostic);
@@ -842,7 +1200,22 @@ if (!hasSingleInstanceLock) {
snapshotConnectIdentityClaim: (profileId, origin) =>
connectDiscovery.snapshotIdentityClaim(profileId, origin),
});
- const sessionSecurity = configureSessionSecurity(credentials);
+ const sessionSecurity = configureDesktopSessionSecurity({
+ contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged),
+ credentials,
+ desktopSession: session.defaultSession,
+ enableRendererNetworkBoundary: process.platform !== 'win32',
+ getMainRenderer: () => mainWindow?.webContents ?? null,
+ isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl),
+ ...(connectSmoke?.journeyEndpoint ? {
+ reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => {
+ log('info', 'desktop.renderer.connect_network_permission', { ...evidence });
+ },
+ reportRendererOwnershipDecision: (evidence: DesktopRendererOwnershipEvidence) => {
+ log('info', PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT, { ...evidence });
+ },
+ } : {}),
+ });
const credentialInitialization = await credentials.initialize();
if (credentialInitialization.status === 'degraded') {
log('warn', 'desktop.credential_revocation.startup_degraded', {
@@ -862,6 +1235,17 @@ if (!hasSingleInstanceLock) {
devServerUrl,
packagedRendererUrl,
openExternal: openAllowedExternalUrl,
+ ...(journeyStages ? {
+ reportAcceptanceJourneyStage: (stage: DesktopAcceptanceJourneyStage) => {
+ journeyStages.record(stage);
+ },
+ reportAcceptanceOperation: (
+ operation: DesktopAcceptanceOperation,
+ status: DesktopAcceptanceOperationStatus,
+ ) => {
+ log('info', PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT, { operation, status });
+ },
+ } : {}),
});
const shutdownLifecycle = transportSmoke?.shutdownMode === 'forced-timeout'
? { shutdown: () => new Promise(() => undefined) }
@@ -883,7 +1267,21 @@ if (!hasSingleInstanceLock) {
mainWindow = await createMainWindow();
if (connectSmoke) {
- await runPackagedConnectDiscoverySmoke(mainWindow);
+ reportPackagedConnectJourneyStage('JOURNEY_DISCOVERY_RENDERER');
+ const readyFields = await runPackagedConnectDiscoverySmoke(mainWindow);
+ if (connectSmoke.journeyEndpoint && connectSmoke.journeyPhase) {
+ if (!journeyStages) throw new Error('Packaged Connect journey stage tracker was unavailable');
+ await runPackagedConnectJourneySmoke(
+ mainWindow,
+ profiles,
+ credentials,
+ connectSmoke.journeyEndpoint,
+ connectSmoke.journeyPhase,
+ journeyStages,
+ );
+ }
+ await publishPackagedConnectReady(readyFields);
+ packagedConnectJourneyDiagnosticState = null;
app.quit();
} else if (transportSmoke) {
await runPackagedTransportSmoke(mainWindow, profiles, credentials, transportSmoke);
@@ -930,6 +1328,13 @@ if (!hasSingleInstanceLock) {
}
});
}).catch(error => {
+ if (packagedConnectJourneyDiagnosticState) {
+ log('error', PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT, {
+ phase: packagedConnectJourneyDiagnosticState.phase,
+ stage: packagedConnectJourneyDiagnosticState.stage,
+ reason: packagedConnectJourneyFailureReason(error),
+ });
+ }
log('error', 'desktop.app.start_failed', { error });
app.exit(1);
});
diff --git a/apps/desktop/src/packaged-approval-session.test.ts b/apps/desktop/src/packaged-approval-session.test.ts
new file mode 100644
index 000000000..152420a51
--- /dev/null
+++ b/apps/desktop/src/packaged-approval-session.test.ts
@@ -0,0 +1,364 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { describe, it } from 'node:test';
+import type { BrowserWindow, Session } from 'electron';
+import {
+ clearPackagedApprovalStorage,
+ createPackagedApprovalNavigation,
+ packagedApprovalPartition,
+} from './packaged-approval-session';
+
+const approvalUrl = `http://127.0.0.1:41731/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`;
+
+type Callback = (decision: T) => void;
+type RequestHandler = (details: Record, callback: Callback>) => void;
+type RedirectHandler = (details: Record) => void;
+type CompletedHandler = (details: Record) => void;
+
+class FakeWebRequest {
+ beforeRequest: RequestHandler | null = null;
+ beforeSendHeaders: RequestHandler | null = null;
+ sendHeaders: RedirectHandler | null = null;
+ headersReceived: RequestHandler | null = null;
+ beforeRedirect: RedirectHandler | null = null;
+ completed: CompletedHandler | null = null;
+
+ onBeforeRequest(handler: RequestHandler | null): void { this.beforeRequest = handler; }
+ onBeforeSendHeaders(handler: RequestHandler | null): void { this.beforeSendHeaders = handler; }
+ onSendHeaders(handler: RedirectHandler | null): void { this.sendHeaders = handler; }
+ onHeadersReceived(handler: RequestHandler | null): void { this.headersReceived = handler; }
+ onBeforeRedirect(handler: RedirectHandler | null): void { this.beforeRedirect = handler; }
+ onCompleted(handler: CompletedHandler | null): void { this.completed = handler; }
+}
+
+class FakeSession extends EventEmitter {
+ readonly webRequest = new FakeWebRequest();
+ permissionCheck: ((...values: unknown[]) => boolean) | null = null;
+ permissionRequest: ((...values: unknown[]) => void) | null = null;
+ clearCount = 0;
+
+ setPermissionCheckHandler(handler: ((...values: unknown[]) => boolean) | null): void {
+ this.permissionCheck = handler;
+ }
+
+ setPermissionRequestHandler(handler: ((...values: unknown[]) => void) | null): void {
+ this.permissionRequest = handler;
+ }
+
+ async clearStorageData(): Promise { this.clearCount += 1; }
+}
+
+class FakeContents extends EventEmitter {
+ readonly id = 91;
+ readonly mainFrame = { detached: false, parent: null };
+ currentUrl = '';
+ openHandler: (() => { action: 'deny' }) | null = null;
+
+ constructor(readonly session: FakeSession) { super(); }
+
+ setWindowOpenHandler(handler: () => { action: 'deny' }): void { this.openHandler = handler; }
+ getURL(): string { return this.currentUrl; }
+}
+
+class FakeWindow {
+ readonly webContents: FakeContents;
+ destroyed = false;
+ destroyCount = 0;
+ load: (url: string) => Promise = async () => undefined;
+
+ constructor(approvalSession: FakeSession) {
+ this.webContents = new FakeContents(approvalSession);
+ }
+
+ loadURL(url: string): Promise { return this.load(url); }
+ isDestroyed(): boolean { return this.destroyed; }
+ destroy(): void { this.destroyed = true; this.destroyCount += 1; }
+}
+
+const event = () => {
+ let prevented = false;
+ return {
+ preventDefault: () => { prevented = true; },
+ get prevented() { return prevented; },
+ };
+};
+
+const decision = async (
+ handler: RequestHandler | null,
+ details: Record,
+): Promise> => {
+ assert.ok(handler);
+ return await new Promise(resolve => handler(details, resolve));
+};
+
+interface Harness {
+ approvalSession: FakeSession;
+ defaultSession: FakeSession;
+ window: FakeWindow;
+ requestHeaders: Record;
+ responseHeaders: Record;
+}
+
+const harness = (statusCode = 200): Harness => {
+ const approvalSession = new FakeSession();
+ const defaultSession = new FakeSession();
+ const window = new FakeWindow(approvalSession);
+ const requestHeaders: Record = { Accept: 'text/html' };
+ const responseHeaders: Record = {
+ 'Content-Type': ['text/html'],
+ 'Set-Cookie': ['approval=secret'],
+ };
+ window.load = async url => {
+ const details = {
+ id: 7,
+ url,
+ method: 'GET',
+ webContentsId: window.webContents.id,
+ webContents: window.webContents,
+ frame: window.webContents.mainFrame,
+ resourceType: 'mainFrame',
+ };
+ const start = await decision(approvalSession.webRequest.beforeRequest, details);
+ if (start.cancel === true) throw new Error('cancelled');
+ const outgoing = await decision(approvalSession.webRequest.beforeSendHeaders, {
+ ...details,
+ requestHeaders,
+ });
+ if (outgoing.cancel === true) throw new Error('cancelled');
+ Object.assign(requestHeaders, outgoing.requestHeaders);
+ approvalSession.webRequest.sendHeaders?.({
+ ...details,
+ requestHeaders,
+ });
+ const incoming = await decision(approvalSession.webRequest.headersReceived, {
+ ...details,
+ statusCode,
+ responseHeaders,
+ });
+ if (incoming.cancel === true) throw new Error('cancelled');
+ for (const name of Object.keys(responseHeaders)) delete responseHeaders[name];
+ Object.assign(responseHeaders, incoming.responseHeaders);
+ window.webContents.currentUrl = url;
+ window.webContents.emit('did-frame-navigate', event(), url, statusCode, 'OK', true, 1, 1);
+ approvalSession.webRequest.completed?.({ ...details, statusCode });
+ };
+ return { approvalSession, defaultSession, window, requestHeaders, responseHeaders };
+};
+
+const controllerFor = (value: Harness) => createPackagedApprovalNavigation({
+ approvalUrl,
+ approvalSession: value.approvalSession as unknown as Session,
+ approvalWindow: value.window as unknown as BrowserWindow,
+ defaultSession: value.defaultSession as unknown as Session,
+});
+
+describe('packaged pairing approval isolated session', () => {
+ it('uses non-persistent unique partition names and rejects invalid entropy', () => {
+ const first = packagedApprovalPartition('a'.repeat(32));
+ const second = packagedApprovalPartition('b'.repeat(32));
+ assert.notEqual(first, second);
+ assert.equal(first.startsWith('persist:'), false);
+ assert.throws(() => packagedApprovalPartition('../shared'));
+ });
+
+ it('allows one exact credentialless main-frame GET and strips response cookies', async () => {
+ const value = harness();
+ const controller = controllerFor(value);
+ assert.equal(value.approvalSession.permissionCheck?.(), false);
+ let permissionAllowed = true;
+ value.approvalSession.permissionRequest?.(null, 'notifications', (allowed: boolean) => {
+ permissionAllowed = allowed;
+ });
+ assert.equal(permissionAllowed, false);
+
+ await controller.navigate();
+
+ assert.equal(Object.keys(value.requestHeaders).some(name => /^(authorization|cookie)$/iu.test(name)), false);
+ assert.equal(Object.keys(value.responseHeaders).some(name => /^set-cookie2?$/iu.test(name)), false);
+ await controller.cleanup();
+ });
+
+ it('waits for an exact completion event that arrives after loadURL resolves', async () => {
+ const value = harness();
+ const controller = controllerFor(value);
+ const originalLoad = value.window.load;
+ let finishCompletion: (() => void) | undefined;
+ value.window.load = async url => {
+ const onCompleted = value.approvalSession.webRequest.completed;
+ assert.ok(onCompleted);
+ value.approvalSession.webRequest.completed = details => {
+ finishCompletion = () => onCompleted(details);
+ };
+ await originalLoad(url);
+ };
+
+ const navigation = controller.navigate();
+ let settled = false;
+ void navigation.finally(() => { settled = true; });
+ await new Promise(resolve => setImmediate(resolve));
+ assert.ok(finishCompletion);
+ assert.equal(settled, false);
+ finishCompletion();
+ await navigation;
+ assert.equal(settled, true);
+ await controller.cleanup();
+ });
+
+ it('cancels an incidental resource without invalidating the exact main-frame approval', async () => {
+ const value = harness();
+ const originalLoad = value.window.load;
+ value.window.load = async url => {
+ const incidental = await decision(value.approvalSession.webRequest.beforeRequest, {
+ id: 6,
+ url: 'http://127.0.0.1:41731/favicon.ico',
+ method: 'GET',
+ webContentsId: value.window.webContents.id,
+ webContents: value.window.webContents,
+ frame: value.window.webContents.mainFrame,
+ resourceType: 'image',
+ });
+ assert.deepEqual(incidental, { cancel: true });
+ await originalLoad(url);
+ };
+
+ const controller = controllerFor(value);
+ await controller.navigate();
+ await controller.cleanup();
+ });
+
+ it('rejects redirects, alternate origins and paths, methods, subframes, and credential headers', async t => {
+ for (const scenario of [
+ 'redirect',
+ 'off-origin',
+ 'path',
+ 'method',
+ 'subframe',
+ 'status',
+ 'authorization',
+ 'cookie',
+ ] as const) {
+ await t.test(scenario, async () => {
+ const value = harness(scenario === 'status' ? 204 : 200);
+ const original = value.window.load;
+ if (scenario === 'redirect') {
+ value.window.load = async url => {
+ value.approvalSession.webRequest.beforeRedirect?.({
+ id: 7,
+ url,
+ method: 'GET',
+ redirectURL: 'https://attacker.example.test/',
+ });
+ const redirect = event();
+ value.window.webContents.emit('will-redirect', redirect);
+ assert.equal(redirect.prevented, true);
+ throw new Error('redirect cancelled');
+ };
+ } else if (scenario === 'status') {
+ // The default loader supplies a non-exact successful response status.
+ } else if (scenario === 'authorization' || scenario === 'cookie') {
+ value.requestHeaders[scenario === 'authorization' ? 'Authorization' : 'Cookie'] = 'secret';
+ } else {
+ value.window.load = async () => {
+ const changed = {
+ id: 7,
+ url: scenario === 'off-origin'
+ ? 'http://127.0.0.2:41731/api/desktop/pairings/other/browser'
+ : scenario === 'path'
+ ? `${approvalUrl}/extra`
+ : approvalUrl,
+ method: scenario === 'method' ? 'POST' : 'GET',
+ webContentsId: value.window.webContents.id,
+ webContents: value.window.webContents,
+ frame: scenario === 'subframe' ? { parent: value.window.webContents.mainFrame } : value.window.webContents.mainFrame,
+ resourceType: scenario === 'subframe' ? 'subFrame' : 'mainFrame',
+ };
+ const result = await decision(value.approvalSession.webRequest.beforeRequest, changed);
+ assert.deepEqual(result, { cancel: true });
+ throw new Error('cancelled');
+ };
+ }
+ const controller = controllerFor(value);
+ await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' });
+ await controller.cleanup();
+ value.window.load = original;
+ });
+ }
+ });
+
+ it('rejects popups, downloads, webviews, and external renderer navigation', async t => {
+ for (const scenario of ['popup', 'download', 'webview', 'navigation'] as const) {
+ await t.test(scenario, async () => {
+ const value = harness();
+ const original = value.window.load;
+ value.window.load = async url => {
+ await original(url);
+ const blocked = event();
+ if (scenario === 'popup') {
+ assert.deepEqual(value.window.webContents.openHandler?.(), { action: 'deny' });
+ } else if (scenario === 'download') {
+ value.approvalSession.emit('will-download', blocked);
+ } else if (scenario === 'webview') {
+ value.window.webContents.emit('will-attach-webview', blocked);
+ } else {
+ value.window.webContents.emit('will-navigate', blocked);
+ }
+ if (scenario !== 'popup') assert.equal(blocked.prevented, true);
+ };
+ const controller = controllerFor(value);
+ await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' });
+ await controller.cleanup();
+ });
+ }
+ });
+
+ it('rejects default/mismatched/reused sessions and cleans up idempotently', async () => {
+ const defaultValue = harness();
+ assert.throws(() => createPackagedApprovalNavigation({
+ approvalUrl,
+ approvalSession: defaultValue.defaultSession as unknown as Session,
+ approvalWindow: defaultValue.window as unknown as BrowserWindow,
+ defaultSession: defaultValue.defaultSession as unknown as Session,
+ }), { message: 'Packaged pairing browser approval was rejected' });
+
+ const value = harness();
+ const controller = controllerFor(value);
+ assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' });
+ await controller.navigate();
+ await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' });
+ const firstCleanup = controller.cleanup();
+ const secondCleanup = controller.cleanup();
+ assert.equal(firstCleanup, secondCleanup);
+ await Promise.all([firstCleanup, secondCleanup]);
+
+ assert.equal(value.window.destroyCount, 1);
+ assert.equal(value.approvalSession.clearCount, 1);
+ assert.equal(value.approvalSession.permissionCheck, null);
+ assert.equal(value.approvalSession.permissionRequest, null);
+ assert.equal(value.approvalSession.listenerCount('will-download'), 0);
+ assert.equal(value.window.webContents.listenerCount('will-navigate'), 0);
+ assert.equal(value.approvalSession.webRequest.beforeRequest, null);
+ assert.equal(value.approvalSession.webRequest.beforeSendHeaders, null);
+ assert.equal(value.approvalSession.webRequest.sendHeaders, null);
+ assert.equal(value.approvalSession.webRequest.headersReceived, null);
+ assert.equal(value.approvalSession.webRequest.beforeRedirect, null);
+ assert.equal(value.approvalSession.webRequest.completed, null);
+ });
+
+ it('bounds storage cleanup, reports failures, and never makes the session reusable', async () => {
+ const stalled = new FakeSession();
+ stalled.clearStorageData = () => new Promise(() => undefined);
+ await assert.rejects(
+ clearPackagedApprovalStorage(stalled as unknown as Session, 5),
+ { message: 'Packaged pairing browser approval cleanup failed' },
+ );
+
+ const value = harness();
+ value.approvalSession.clearStorageData = async () => { throw new Error('private cleanup detail'); };
+ const controller = controllerFor(value);
+ await controller.navigate();
+ const firstCleanup = controller.cleanup();
+ assert.equal(firstCleanup, controller.cleanup());
+ await assert.rejects(firstCleanup, { message: 'Packaged pairing browser approval cleanup failed' });
+ assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' });
+ });
+});
diff --git a/apps/desktop/src/packaged-approval-session.ts b/apps/desktop/src/packaged-approval-session.ts
new file mode 100644
index 000000000..e53b377cc
--- /dev/null
+++ b/apps/desktop/src/packaged-approval-session.ts
@@ -0,0 +1,334 @@
+import type {
+ BrowserWindow,
+ Event as ElectronEvent,
+ OnBeforeRedirectListenerDetails,
+ OnBeforeRequestListenerDetails,
+ OnBeforeSendHeadersListenerDetails,
+ OnCompletedListenerDetails,
+ OnHeadersReceivedListenerDetails,
+ OnSendHeadersListenerDetails,
+ Session,
+ WebContentsWillNavigateEventParams,
+ WebContentsWillRedirectEventParams,
+} from 'electron';
+
+const APPROVAL_REJECTED = 'Packaged pairing browser approval was rejected';
+const APPROVAL_CLEANUP_REJECTED = 'Packaged pairing browser approval cleanup failed';
+const APPROVAL_STATUS = 200;
+const APPROVAL_COMPLETION_TIMEOUT_MS = 5_000;
+const APPROVAL_CLEANUP_TIMEOUT_MS = 5_000;
+const claimedSessions = new WeakSet();
+
+export const packagedApprovalPartition = (nonce: string): string => {
+ if (!/^[a-f0-9]{32}$/u.test(nonce)) throw rejected();
+ return `propr-packaged-approval-${nonce}`;
+};
+
+export interface PackagedApprovalNavigation {
+ navigate(): Promise;
+ cleanup(): Promise;
+}
+
+interface PackagedApprovalNavigationOptions {
+ approvalUrl: string;
+ approvalSession: Session;
+ approvalWindow: BrowserWindow;
+ defaultSession: Session;
+}
+
+function rejected(): Error {
+ return new Error(APPROVAL_REJECTED);
+}
+
+function cleanupRejected(): Error {
+ return new Error(APPROVAL_CLEANUP_REJECTED);
+}
+
+export const clearPackagedApprovalStorage = async (
+ approvalSession: Pick,
+ timeoutMs = APPROVAL_CLEANUP_TIMEOUT_MS,
+): Promise => {
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > APPROVAL_CLEANUP_TIMEOUT_MS) {
+ throw cleanupRejected();
+ }
+ let timeout: ReturnType | undefined;
+ try {
+ await Promise.race([
+ approvalSession.clearStorageData(),
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => reject(cleanupRejected()), timeoutMs);
+ }),
+ ]);
+ } catch {
+ throw cleanupRejected();
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+};
+
+const containsCredentialHeaders = (headers: Record): boolean =>
+ Object.keys(headers).some(name => {
+ const normalized = name.toLowerCase();
+ return normalized === 'authorization' || normalized === 'cookie' || normalized === 'proxy-authorization';
+ });
+
+const withoutSetCookie = (
+ headers: Record | undefined,
+): Record => Object.fromEntries(
+ Object.entries(headers ?? {}).filter(([name]) => {
+ const normalized = name.toLowerCase();
+ return normalized !== 'set-cookie' && normalized !== 'set-cookie2';
+ }),
+);
+
+/**
+ * Constrain the packaged acceptance harness to one isolated, credentialless browser
+ * navigation. This session is deliberately unrelated to the production renderer
+ * and credential transport session.
+ */
+export const createPackagedApprovalNavigation = ({
+ approvalUrl,
+ approvalSession,
+ approvalWindow,
+ defaultSession,
+}: PackagedApprovalNavigationOptions): PackagedApprovalNavigation => {
+ const contents = approvalWindow.webContents;
+ if (approvalSession === defaultSession
+ || contents.session !== approvalSession
+ || claimedSessions.has(approvalSession)) {
+ throw rejected();
+ }
+ claimedSessions.add(approvalSession);
+
+ let active = true;
+ let navigated = false;
+ let allowedRequestId: number | null = null;
+ let responseStatus: number | null = null;
+ let requestSent = false;
+ let committedStatus: number | null = null;
+ let committedUrl: string | null = null;
+ let completedStatus: number | null = null;
+ let boundaryRejected = false;
+ let completionResolve: (() => void) | null = null;
+ let cleanupPromise: Promise | null = null;
+
+ const releaseCompletionWait = (): void => {
+ completionResolve?.();
+ completionResolve = null;
+ };
+ const rejectBoundary = (): void => {
+ boundaryRejected = true;
+ releaseCompletionWait();
+ };
+ const ownsMainFrame = (details: {
+ webContentsId?: number;
+ webContents?: Electron.WebContents;
+ frame?: Electron.WebFrameMain | null;
+ resourceType: string;
+ }): boolean => details.webContentsId === contents.id
+ && (details.webContents === undefined || details.webContents === contents)
+ && details.resourceType === 'mainFrame'
+ && (details.frame === undefined || details.frame === contents.mainFrame);
+
+ const exactAllowedRequest = (details: {
+ id: number;
+ url: string;
+ method: string;
+ webContentsId?: number;
+ webContents?: Electron.WebContents;
+ frame?: Electron.WebFrameMain | null;
+ resourceType: string;
+ }): boolean => active
+ && details.id === allowedRequestId
+ && details.url === approvalUrl
+ && details.method === 'GET'
+ && ownsMainFrame(details);
+
+ approvalSession.setPermissionCheckHandler(() => false);
+ approvalSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
+
+ const onBeforeRequest = (details: OnBeforeRequestListenerDetails, callback: (decision: {
+ cancel?: boolean;
+ }) => void): void => {
+ if (details.resourceType !== 'mainFrame') {
+ if (details.resourceType === 'subFrame') rejectBoundary();
+ callback({ cancel: true });
+ return;
+ }
+ const allowed = active
+ && allowedRequestId === null
+ && details.url === approvalUrl
+ && details.method === 'GET'
+ && ownsMainFrame(details);
+ if (!allowed) {
+ rejectBoundary();
+ callback({ cancel: true });
+ return;
+ }
+ allowedRequestId = details.id;
+ callback({});
+ };
+
+ const onBeforeSendHeaders = (
+ details: OnBeforeSendHeadersListenerDetails,
+ callback: (decision: { cancel?: boolean; requestHeaders?: Record }) => void,
+ ): void => {
+ if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) {
+ rejectBoundary();
+ callback({ cancel: true });
+ return;
+ }
+ callback({ requestHeaders: details.requestHeaders });
+ };
+
+ const onHeadersReceived = (
+ details: OnHeadersReceivedListenerDetails,
+ callback: (decision: { cancel?: boolean; responseHeaders?: Record }) => void,
+ ): void => {
+ if (!exactAllowedRequest(details) || details.statusCode !== APPROVAL_STATUS) {
+ rejectBoundary();
+ callback({ cancel: true });
+ return;
+ }
+ responseStatus = details.statusCode;
+ callback({ responseHeaders: withoutSetCookie(details.responseHeaders) });
+ };
+
+ const onSendHeaders = (details: OnSendHeadersListenerDetails): void => {
+ if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) {
+ rejectBoundary();
+ return;
+ }
+ requestSent = true;
+ };
+
+ const onBeforeRedirect = (_details: OnBeforeRedirectListenerDetails): void => {
+ rejectBoundary();
+ };
+ const onCompleted = (details: OnCompletedListenerDetails): void => {
+ if (!exactAllowedRequest(details) || details.statusCode !== responseStatus) {
+ rejectBoundary();
+ return;
+ }
+ completedStatus = details.statusCode;
+ releaseCompletionWait();
+ };
+ approvalSession.webRequest.onBeforeRequest(onBeforeRequest);
+ approvalSession.webRequest.onBeforeSendHeaders(onBeforeSendHeaders);
+ approvalSession.webRequest.onSendHeaders(onSendHeaders);
+ approvalSession.webRequest.onHeadersReceived(onHeadersReceived);
+ approvalSession.webRequest.onBeforeRedirect(onBeforeRedirect);
+ approvalSession.webRequest.onCompleted(onCompleted);
+
+ const onWillNavigate = (event: ElectronEvent): void => {
+ rejectBoundary();
+ event.preventDefault();
+ };
+ const onWillRedirect = (event: ElectronEvent): void => {
+ rejectBoundary();
+ event.preventDefault();
+ };
+ const onDidFrameNavigate = (
+ _event: ElectronEvent,
+ url: string,
+ status: number,
+ _statusText: string,
+ isMainFrame: boolean,
+ ): void => {
+ if (!isMainFrame || url !== approvalUrl || status !== responseStatus) {
+ rejectBoundary();
+ return;
+ }
+ committedUrl = url;
+ committedStatus = status;
+ };
+ const onDidNavigateInPage = (
+ _event: ElectronEvent,
+ url: string,
+ isMainFrame: boolean,
+ ): void => {
+ // An exact no-op history replacement is the only same-document behavior allowed.
+ if (!isMainFrame || url !== approvalUrl) rejectBoundary();
+ };
+ const onWillAttachWebview = (event: ElectronEvent): void => {
+ rejectBoundary();
+ event.preventDefault();
+ };
+ const onWillDownload = (event: ElectronEvent): void => {
+ rejectBoundary();
+ event.preventDefault();
+ };
+
+ contents.setWindowOpenHandler(() => {
+ rejectBoundary();
+ return { action: 'deny' };
+ });
+ contents.on('will-navigate', onWillNavigate);
+ contents.on('will-redirect', onWillRedirect);
+ contents.on('did-frame-navigate', onDidFrameNavigate);
+ contents.on('did-navigate-in-page', onDidNavigateInPage);
+ contents.on('will-attach-webview', onWillAttachWebview);
+ approvalSession.on('will-download', onWillDownload);
+
+ return {
+ async navigate() {
+ if (!active || navigated) throw rejected();
+ navigated = true;
+ const completion = completedStatus !== null || boundaryRejected
+ ? Promise.resolve()
+ : new Promise(resolve => { completionResolve = resolve; });
+ let timeout: ReturnType | undefined;
+ try {
+ await Promise.race([
+ (async () => {
+ await approvalWindow.loadURL(approvalUrl);
+ await completion;
+ })(),
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => reject(rejected()), APPROVAL_COMPLETION_TIMEOUT_MS);
+ }),
+ ]);
+ } catch {
+ throw rejected();
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ completionResolve = null;
+ }
+ if (!active
+ || boundaryRejected
+ || allowedRequestId === null
+ || !requestSent
+ || responseStatus === null
+ || responseStatus !== committedStatus
+ || responseStatus !== completedStatus
+ || committedUrl !== approvalUrl
+ || contents.getURL() !== approvalUrl) {
+ throw rejected();
+ }
+ },
+ cleanup() {
+ if (cleanupPromise) return cleanupPromise;
+ cleanupPromise = (async () => {
+ active = false;
+ releaseCompletionWait();
+ if (!approvalWindow.isDestroyed()) approvalWindow.destroy();
+ contents.off('will-navigate', onWillNavigate);
+ contents.off('will-redirect', onWillRedirect);
+ contents.off('did-frame-navigate', onDidFrameNavigate);
+ contents.off('did-navigate-in-page', onDidNavigateInPage);
+ contents.off('will-attach-webview', onWillAttachWebview);
+ approvalSession.off('will-download', onWillDownload);
+ approvalSession.setPermissionCheckHandler(null);
+ approvalSession.setPermissionRequestHandler(null);
+ approvalSession.webRequest.onBeforeRequest(null);
+ approvalSession.webRequest.onBeforeSendHeaders(null);
+ approvalSession.webRequest.onSendHeaders(null);
+ approvalSession.webRequest.onHeadersReceived(null);
+ approvalSession.webRequest.onBeforeRedirect(null);
+ approvalSession.webRequest.onCompleted(null);
+ await clearPackagedApprovalStorage(approvalSession);
+ })();
+ return cleanupPromise;
+ },
+ };
+};
diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts
index 8b38ac70b..1da9cfa34 100644
--- a/apps/desktop/src/preload-bridge.test.ts
+++ b/apps/desktop/src/preload-bridge.test.ts
@@ -70,6 +70,17 @@ describe('desktop preload bridge', () => {
assert.deepEqual(Object.keys(bridge.discovery).sort(), ['discover', 'rediscover', 'supported']);
});
+ it('exposes only a fixed stage reporter when packaged Connect acceptance is authorized', async () => {
+ const ipc = new FakeIpc();
+ const bridge = createDesktopBridge(ipc, true, true);
+ assert.deepEqual(Object.keys(bridge.acceptance ?? {}), ['reportJourneyStage']);
+ await bridge.acceptance?.reportJourneyStage('CREDENTIAL_COMMITTED');
+ assert.deepEqual(ipc.invocations, [{
+ channel: IPC_CHANNELS.acceptanceJourneyStage,
+ args: ['CREDENTIAL_COMMITTED'],
+ }]);
+ });
+
it('does not expose Electron event objects to deep-link listeners', () => {
const ipc = new FakeIpc();
const bridge = createDesktopBridge(ipc);
diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts
index 10c07bef1..3a6e6e3d3 100644
--- a/apps/desktop/src/preload-bridge.ts
+++ b/apps/desktop/src/preload-bridge.ts
@@ -15,6 +15,7 @@ export const createDesktopBridge = (
connectDiscoverySupported = process.platform === 'darwin'
|| process.platform === 'linux'
|| process.platform === 'win32',
+ connectJourneyAcceptance = false,
): DesktopBridge => {
const deepLinkListeners = new Set<(url: string) => void>();
const pendingDeepLinks: string[] = [];
@@ -71,6 +72,11 @@ export const createDesktopBridge = (
stop: () => invoke(ipc, IPC_CHANNELS.lifecycleStop),
restart: () => invoke(ipc, IPC_CHANNELS.lifecycleRestart),
},
+ ...(connectJourneyAcceptance ? {
+ acceptance: {
+ reportJourneyStage: (stage) => invoke(ipc, IPC_CHANNELS.acceptanceJourneyStage, stage),
+ },
+ } : {}),
};
Object.values(bridge).forEach(Object.freeze);
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index ba4f4d45b..165e7f187 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -1,4 +1,11 @@
import { contextBridge, ipcRenderer } from 'electron';
import { createDesktopBridge } from './preload-bridge';
-contextBridge.exposeInMainWorld('proprDesktop', createDesktopBridge(ipcRenderer));
+const connectJourneyAcceptance = process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST === '1'
+ && (process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'pair'
+ || process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'reprobe');
+
+contextBridge.exposeInMainWorld(
+ 'proprDesktop',
+ createDesktopBridge(ipcRenderer, undefined, connectJourneyAcceptance),
+);
diff --git a/apps/desktop/src/session-security.test.ts b/apps/desktop/src/session-security.test.ts
new file mode 100644
index 000000000..efbdc9d74
--- /dev/null
+++ b/apps/desktop/src/session-security.test.ts
@@ -0,0 +1,349 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, it } from 'node:test';
+import type { Session, WebContents, WebFrameMain } from 'electron';
+import {
+ DESKTOP_RENDERER_ORIGIN,
+ DESKTOP_TRANSPORT_SCOPE_HEADER,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+} from '@propr/shared';
+import { DesktopCredentialService } from './credential-service';
+import { ProfileStore, type EncryptionProvider } from './profile-store';
+import {
+ configureDesktopSessionSecurity,
+ desktopNetworkPermissionAllowed,
+ type DesktopNetworkPermissionEvidence,
+ type DesktopRendererOwnershipEvidence,
+} from './session-security';
+
+const RENDERER_URL = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
+const ACTIVE_ORIGIN = 'http://127.0.0.2:41731';
+const TOKEN = `propr_it_${'T'.repeat(43)}`;
+const IDENTITY = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+
+const encryption: EncryptionProvider = {
+ isEncryptionAvailable: () => true,
+ backend: () => 'keychain',
+ encrypt: value => Buffer.from(value, 'utf8'),
+ decrypt: value => value.toString('utf8'),
+};
+
+const discovery = {
+ schemaVersion: 1,
+ product: 'ProPR',
+ version: '0.8.15',
+ apiCompatibility: PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ canonicalEndpoint: null,
+ publicInstanceIdentity: IDENTITY,
+ desktopAuthentication: {
+ protocolVersion: 2,
+ browserPairing: true,
+ instanceBearerTokens: true,
+ socketIoBearerAuthentication: true,
+ },
+};
+
+describe('production desktop session security', () => {
+ it('denies every permission except active trusted-main-frame local network access', () => {
+ const accepted = {
+ activeBindingCurrent: true,
+ decision: 'check' as const,
+ isMainFrame: true,
+ mainWindowPresent: true,
+ permission: 'loopback-network',
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityEqual: true,
+ requestingOriginAuthorityValid: true,
+ requestingUrlAuthorityEqual: true,
+ requestingUrlPresent: false,
+ requestingUrlTrusted: false,
+ webContentsEqualsMainWindow: false,
+ webContentsPresent: false,
+ };
+ assert.equal(desktopNetworkPermissionAllowed(accepted), true);
+ for (const rejected of [
+ { activeBindingCurrent: false },
+ { isMainFrame: false },
+ { mainWindowPresent: false },
+ { permission: 'notifications' },
+ { rendererDocumentUrlTrusted: false },
+ { requestingOriginAuthorityEqual: false },
+ { requestingOriginAuthorityValid: false },
+ { webContentsPresent: true },
+ ]) {
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, ...rejected }), false);
+ }
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network' }), true);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network-access' }), true);
+ assert.equal(desktopNetworkPermissionAllowed({
+ ...accepted,
+ decision: 'request',
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ webContentsEqualsMainWindow: true,
+ webContentsPresent: true,
+ }), true);
+ });
+
+ it('pins permission and concrete credential transport to the live main renderer and current origin', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'propr-session-security-'));
+ const store = new ProfileStore(directory, encryption);
+ let connectClaimCurrent = true;
+ const service = new DesktopCredentialService({
+ profiles: store,
+ clientName: 'Session security test',
+ openPairingBrowser: async () => undefined,
+ fetch: async input => input.toString().endsWith('/api/desktop/discovery')
+ ? new Response(JSON.stringify(discovery), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ : new Response(JSON.stringify({ username: 'octocat' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ snapshotConnectIdentityClaim: () => ({
+ status: 'unclaimed',
+ isCurrent: () => connectClaimCurrent,
+ beginCommit: () => () => undefined,
+ }),
+ });
+ try {
+ const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: ACTIVE_ORIGIN });
+ await store.writeCredential({
+ version: 2,
+ profileId: profile.id,
+ origin: ACTIVE_ORIGIN,
+ publicInstanceIdentity: IDENTITY,
+ token: TOKEN,
+ });
+ const ready = await service.probe(profile);
+ assert.equal(ready.status, 'ready');
+ if (ready.status !== 'ready') return;
+
+ type PermissionCheck = (
+ webContents: WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ details: { requestingUrl?: string; isMainFrame: boolean },
+ ) => boolean;
+ type PermissionRequest = (
+ webContents: WebContents,
+ permission: string,
+ callback: (allowed: boolean) => void,
+ details: { requestingUrl?: string; isMainFrame: boolean },
+ ) => void;
+ type BeforeSendHeaders = (
+ details: {
+ url: string;
+ method: string;
+ resourceType: string;
+ requestHeaders: Record;
+ webContentsId: number;
+ webContents?: WebContents;
+ frame?: WebFrameMain | null;
+ },
+ callback: (decision: Record) => void,
+ ) => void;
+ let permissionCheck: PermissionCheck = () => false;
+ let permissionRequest: PermissionRequest = () => undefined;
+ let beforeSendHeaders: BeforeSendHeaders = () => undefined;
+ const evidence: DesktopNetworkPermissionEvidence[] = [];
+ const ownershipEvidence: DesktopRendererOwnershipEvidence[] = [];
+ const desktopSession = {
+ setPermissionCheckHandler: (handler: PermissionCheck | null) => {
+ if (handler) permissionCheck = handler;
+ },
+ setPermissionRequestHandler: (handler: PermissionRequest | null) => {
+ if (handler) permissionRequest = handler;
+ },
+ webRequest: {
+ onBeforeSendHeaders: (handler: BeforeSendHeaders | null) => {
+ if (handler) beforeSendHeaders = handler;
+ },
+ onHeadersReceived: () => undefined,
+ },
+ } as unknown as Session;
+ let destroyed = false;
+ let rendererUrl = RENDERER_URL;
+ const mainFrame = {
+ detached: false,
+ parent: null,
+ url: RENDERER_URL,
+ } as unknown as WebFrameMain;
+ const mainRenderer = {
+ id: 41,
+ getURL: () => rendererUrl,
+ isDestroyed: () => destroyed,
+ mainFrame,
+ } as unknown as WebContents;
+ const foreignRenderer = {
+ id: 42,
+ getURL: () => RENDERER_URL,
+ isDestroyed: () => false,
+ } as unknown as WebContents;
+ configureDesktopSessionSecurity({
+ contentSecurityPolicy: () => "default-src 'self'",
+ credentials: service,
+ desktopSession,
+ getMainRenderer: () => mainRenderer,
+ isTrustedRendererUrl: value => value === RENDERER_URL,
+ reportNetworkPermissionDecision: record => evidence.push(record),
+ reportRendererOwnershipDecision: record => ownershipEvidence.push(record),
+ });
+
+ const check = (
+ webContents: WebContents | null = null,
+ origin = DESKTOP_RENDERER_ORIGIN,
+ details: { requestingUrl?: string; isMainFrame: boolean } = { isMainFrame: true },
+ ) => permissionCheck(webContents, 'loopback-network', origin, details);
+ assert.equal(check(), false);
+ const activated = await service.activate(ready.activationTicket);
+ assert.equal(check(), true);
+ assert.equal(check(foreignRenderer, DESKTOP_RENDERER_ORIGIN, {
+ requestingUrl: RENDERER_URL,
+ isMainFrame: true,
+ }), false);
+ assert.equal(check(null, 'https://attacker.example.test'), false);
+ assert.equal(check(null, DESKTOP_RENDERER_ORIGIN, { isMainFrame: false }), false);
+ rendererUrl = 'https://attacker.example.test/renderer.html';
+ assert.equal(check(), false);
+ rendererUrl = RENDERER_URL;
+ destroyed = true;
+ assert.equal(check(), false);
+ destroyed = false;
+
+ let requested = false;
+ permissionRequest(mainRenderer, 'local-network-access', value => { requested = value; }, {
+ requestingUrl: RENDERER_URL,
+ isMainFrame: true,
+ });
+ assert.equal(requested, true);
+ permissionRequest(foreignRenderer, 'local-network-access', value => { requested = value; }, {
+ requestingUrl: RENDERER_URL,
+ isMainFrame: true,
+ });
+ assert.equal(requested, false);
+
+ const intercepted = async (
+ url: string,
+ headers: Record,
+ webContentsId = mainRenderer.id,
+ resourceType = 'xhr',
+ frame: WebFrameMain | null = mainFrame,
+ omitFrame = false,
+ ) => await new Promise>(resolve => beforeSendHeaders({
+ url,
+ method: 'GET',
+ resourceType,
+ requestHeaders: headers,
+ webContentsId,
+ ...(!omitFrame ? { frame } : {}),
+ }, resolve));
+ const scopeHeaders = {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Authorization: 'Bearer renderer-controlled',
+ Cookie: 'renderer=must-not-cross',
+ [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope,
+ };
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), {
+ requestHeaders: {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Authorization: `Bearer ${TOKEN}`,
+ },
+ });
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', mainFrame, true,
+ ), { cancel: true });
+ assert.equal(ownershipEvidence.at(-1)?.frameOmitted, true);
+ assert.equal(ownershipEvidence.at(-1)?.rendererOwned, false);
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'other', mainFrame, true,
+ ), { cancel: true });
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', null,
+ ), { cancel: true });
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`,
+ scopeHeaders,
+ mainRenderer.id,
+ 'mainFrame',
+ ), { cancel: true });
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, foreignRenderer.id), {
+ cancel: true,
+ });
+ const childFrame = {
+ detached: false,
+ parent: mainFrame,
+ url: RENDERER_URL,
+ } as unknown as WebFrameMain;
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', childFrame,
+ ), { cancel: true });
+ const foreignDocument = {
+ detached: false,
+ parent: null,
+ url: 'https://attacker.example.test/renderer.html',
+ } as unknown as WebFrameMain;
+ assert.deepEqual(await intercepted(
+ `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', foreignDocument,
+ ), { cancel: true });
+ for (const target of [
+ 'http://127.0.0.1:41731/api/side-effect',
+ 'http://127.0.0.3:41731/api/side-effect',
+ 'https://192.168.1.10/api/side-effect',
+ ]) {
+ assert.deepEqual(await intercepted(target, {
+ Authorization: 'Bearer renderer-controlled',
+ Cookie: 'renderer=must-not-cross',
+ }), { cancel: true }, target);
+ }
+ connectClaimCurrent = false;
+ assert.equal(check(), false);
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true });
+ connectClaimCurrent = true;
+ assert.equal(check(), true);
+ destroyed = true;
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true });
+ destroyed = false;
+
+ assert.deepEqual(await service.discardActivation(activated), { discarded: true });
+ assert.equal(check(), false);
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, {
+ Authorization: 'Bearer renderer-controlled',
+ Cookie: 'renderer=must-not-cross',
+ }), { cancel: true });
+
+ const revokedReady = await service.probe(profile);
+ assert.equal(revokedReady.status, 'ready');
+ if (revokedReady.status !== 'ready') return;
+ const revoked = await service.activate(revokedReady.activationTicket);
+ assert.deepEqual(await service.invalidate({
+ profileId: profile.id,
+ transportScope: revoked.transportScope,
+ code: 'INSTANCE_TOKEN_REVOKED',
+ }), { invalidated: true });
+ assert.equal(check(), false);
+ assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, {}), { cancel: true });
+
+ assert.ok(evidence.length >= 10);
+ assert.doesNotMatch(
+ JSON.stringify(evidence),
+ /attacker|renderer\.html|127\.0\.0\.2|192\.168|propr_it_/u,
+ );
+ assert.ok(evidence.every(record => Object.keys(record).sort().join(',') === [
+ 'activeBindingCurrent', 'allowed', 'decision', 'isMainFrame', 'mainWindowPresent',
+ 'permissionCategory', 'rendererDocumentUrlTrusted', 'requestingOriginAuthorityEqual',
+ 'requestingOriginAuthorityValid', 'requestingUrlPresent', 'requestingUrlTrusted',
+ 'schemaVersion', 'webContentsEqualsMainWindow', 'webContentsPresent',
+ ].sort().join(',')));
+ } finally {
+ await service.dispose();
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/desktop/src/session-security.ts b/apps/desktop/src/session-security.ts
new file mode 100644
index 000000000..a13e6a0e6
--- /dev/null
+++ b/apps/desktop/src/session-security.ts
@@ -0,0 +1,292 @@
+import type { Session, WebContents } from 'electron';
+import type { DesktopCredentialService } from './credential-service';
+
+const DESKTOP_NETWORK_PERMISSIONS = new Set([
+ // Chromium split the original permission into address-space-specific
+ // permissions. Keep the original spelling for older supported runtimes.
+ 'local-network-access',
+ 'local-network',
+ 'loopback-network',
+]);
+
+export type DesktopNetworkPermissionCategory =
+ | 'local-network-access'
+ | 'local-network'
+ | 'loopback-network';
+
+export interface DesktopNetworkPermissionEvidence {
+ schemaVersion: 1;
+ permissionCategory: DesktopNetworkPermissionCategory;
+ decision: 'check' | 'request';
+ allowed: boolean;
+ activeBindingCurrent: boolean;
+ webContentsPresent: boolean;
+ webContentsEqualsMainWindow: boolean;
+ mainWindowPresent: boolean;
+ isMainFrame: boolean;
+ requestingUrlPresent: boolean;
+ requestingUrlTrusted: boolean;
+ rendererDocumentUrlTrusted: boolean;
+ requestingOriginAuthorityValid: boolean;
+ requestingOriginAuthorityEqual: boolean;
+}
+
+export interface DesktopRendererOwnershipEvidence {
+ schemaVersion: 1;
+ resourceCategory: 'xhr' | 'webSocket' | 'other';
+ mainRendererPresent: boolean;
+ mainRendererLive: boolean;
+ webContentsIdMatches: boolean;
+ webContentsAbsentOrMatches: boolean;
+ mainFrameLive: boolean;
+ rendererDocumentTrusted: boolean;
+ rendererDocumentAuthorityEqual: boolean;
+ frameOmitted: boolean;
+ framePresent: boolean;
+ frameMatchesMainFrame: boolean;
+ frameExplicitlyForeign: boolean;
+ rendererOwned: boolean;
+}
+
+const rendererAuthority = (value: string): string | null => {
+ try {
+ const url = new URL(value);
+ if (!url.protocol || !url.hostname || url.username || url.password) return null;
+ return `${url.protocol}//${url.host}`;
+ } catch {
+ return null;
+ }
+};
+
+export interface DesktopNetworkPermissionContext extends Omit {
+ permission: string;
+ requestingUrlAuthorityEqual: boolean;
+}
+
+/** Local Network Access is available only to the live trusted main frame with a current binding. */
+export const desktopNetworkPermissionAllowed = ({
+ activeBindingCurrent,
+ decision,
+ isMainFrame,
+ mainWindowPresent,
+ permission,
+ rendererDocumentUrlTrusted,
+ requestingOriginAuthorityEqual,
+ requestingOriginAuthorityValid,
+ requestingUrlAuthorityEqual,
+ requestingUrlPresent,
+ requestingUrlTrusted,
+ webContentsEqualsMainWindow,
+ webContentsPresent,
+}: DesktopNetworkPermissionContext): boolean => DESKTOP_NETWORK_PERMISSIONS.has(permission)
+ && activeBindingCurrent
+ && mainWindowPresent
+ && isMainFrame
+ && rendererDocumentUrlTrusted
+ && (!requestingUrlPresent || (requestingUrlTrusted && requestingUrlAuthorityEqual))
+ && requestingOriginAuthorityValid
+ && requestingOriginAuthorityEqual
+ && (decision === 'check'
+ ? !webContentsPresent || webContentsEqualsMainWindow
+ : webContentsPresent && webContentsEqualsMainWindow && requestingUrlPresent);
+
+interface ConfigureDesktopSessionSecurityOptions {
+ contentSecurityPolicy(): string;
+ credentials: DesktopCredentialService;
+ desktopSession: Session;
+ enableRendererNetworkBoundary?: boolean;
+ getMainRenderer(): WebContents | null;
+ isTrustedRendererUrl(value: string): boolean;
+ reportNetworkPermissionDecision?(evidence: DesktopNetworkPermissionEvidence): void;
+ reportRendererOwnershipDecision?(evidence: DesktopRendererOwnershipEvidence): void;
+}
+
+/** Install the production permission, concrete-request, and response boundary on one session. */
+export const configureDesktopSessionSecurity = ({
+ contentSecurityPolicy,
+ credentials,
+ desktopSession,
+ enableRendererNetworkBoundary = true,
+ getMainRenderer,
+ isTrustedRendererUrl,
+ reportNetworkPermissionDecision = () => undefined,
+ reportRendererOwnershipDecision = () => undefined,
+}: ConfigureDesktopSessionSecurityOptions): {
+ close(): void;
+ dispose(): void;
+} => {
+ const allowNetworkPermission = (
+ decision: 'check' | 'request',
+ webContents: WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ isMainFrame: boolean,
+ requestingUrl?: string,
+ ): boolean => {
+ const candidate = getMainRenderer();
+ const mainRenderer = candidate !== null && !candidate.isDestroyed() ? candidate : null;
+ const rendererDocumentUrl = mainRenderer?.getURL() ?? '';
+ const rendererDocumentAuthority = rendererAuthority(rendererDocumentUrl);
+ const requestingUrlPresent = typeof requestingUrl === 'string' && requestingUrl.length > 0;
+ const requestingUrlAuthority = requestingUrlPresent ? rendererAuthority(requestingUrl) : null;
+ const requestingOriginAuthority = rendererAuthority(requestingOrigin);
+ const context: DesktopNetworkPermissionContext = {
+ activeBindingCurrent: credentials.hasActiveRendererBinding(),
+ decision,
+ isMainFrame: isMainFrame === true,
+ mainWindowPresent: mainRenderer !== null,
+ permission,
+ rendererDocumentUrlTrusted: mainRenderer !== null && isTrustedRendererUrl(rendererDocumentUrl),
+ requestingOriginAuthorityEqual: rendererDocumentAuthority !== null
+ && requestingOriginAuthority === rendererDocumentAuthority,
+ requestingOriginAuthorityValid: requestingOriginAuthority !== null
+ && requestingOrigin === requestingOriginAuthority,
+ requestingUrlAuthorityEqual: !requestingUrlPresent || (rendererDocumentAuthority !== null
+ && requestingUrlAuthority === rendererDocumentAuthority),
+ requestingUrlPresent,
+ requestingUrlTrusted: requestingUrlPresent && isTrustedRendererUrl(requestingUrl),
+ webContentsEqualsMainWindow: webContents !== null && webContents === mainRenderer,
+ webContentsPresent: webContents !== null,
+ };
+ const allowed = desktopNetworkPermissionAllowed(context);
+ if (DESKTOP_NETWORK_PERMISSIONS.has(permission)) {
+ try {
+ reportNetworkPermissionDecision({
+ schemaVersion: 1,
+ permissionCategory: permission as DesktopNetworkPermissionCategory,
+ decision,
+ allowed,
+ activeBindingCurrent: context.activeBindingCurrent,
+ webContentsPresent: context.webContentsPresent,
+ webContentsEqualsMainWindow: context.webContentsEqualsMainWindow,
+ mainWindowPresent: context.mainWindowPresent,
+ isMainFrame: context.isMainFrame,
+ requestingUrlPresent: context.requestingUrlPresent,
+ requestingUrlTrusted: context.requestingUrlTrusted,
+ rendererDocumentUrlTrusted: context.rendererDocumentUrlTrusted,
+ requestingOriginAuthorityValid: context.requestingOriginAuthorityValid,
+ requestingOriginAuthorityEqual: context.requestingOriginAuthorityEqual,
+ });
+ } catch {
+ // Fixed diagnostics cannot alter the permission decision.
+ }
+ }
+ return allowed;
+ };
+
+ if (enableRendererNetworkBoundary) {
+ desktopSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) =>
+ allowNetworkPermission(
+ 'check',
+ webContents,
+ String(permission),
+ requestingOrigin,
+ details.isMainFrame,
+ details.requestingUrl,
+ ));
+ desktopSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
+ const requestingUrl = 'requestingUrl' in details && typeof details.requestingUrl === 'string'
+ ? details.requestingUrl
+ : undefined;
+ callback(allowNetworkPermission(
+ 'request',
+ webContents,
+ String(permission),
+ requestingUrl ? rendererAuthority(requestingUrl) ?? '' : '',
+ details.isMainFrame,
+ requestingUrl,
+ ));
+ });
+ } else {
+ desktopSession.setPermissionCheckHandler(() => false);
+ desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
+ }
+ desktopSession.webRequest.onBeforeSendHeaders((details, callback) => {
+ const mainRenderer = getMainRenderer();
+ const requestingFrame = details.frame;
+ const mainFrame = mainRenderer?.mainFrame;
+ const mainRendererLive = mainRenderer !== null && !mainRenderer.isDestroyed();
+ const mainFrameLive = mainRendererLive
+ && mainFrame !== undefined
+ && mainFrame !== null
+ && !mainFrame.detached
+ && mainFrame.parent === null;
+ const rendererDocumentUrl = mainRendererLive ? mainRenderer.getURL() : '';
+ const mainFrameUrl = mainFrameLive ? mainFrame.url : '';
+ const rendererDocumentTrusted = mainFrameLive
+ && isTrustedRendererUrl(rendererDocumentUrl)
+ && isTrustedRendererUrl(mainFrameUrl);
+ const rendererDocumentAuthorityEqual = rendererDocumentTrusted
+ && rendererAuthority(rendererDocumentUrl) !== null
+ && rendererAuthority(rendererDocumentUrl) === rendererAuthority(mainFrameUrl)
+ && rendererDocumentUrl === mainFrameUrl;
+ const webContentsIdMatches = mainRendererLive && details.webContentsId === mainRenderer.id;
+ const webContentsAbsentOrMatches = mainRendererLive
+ && (details.webContents === undefined || details.webContents === mainRenderer);
+ const frameOmitted = requestingFrame === undefined;
+ const framePresent = requestingFrame !== undefined && requestingFrame !== null;
+ const frameMatchesMainFrame = framePresent
+ && mainFrame !== undefined
+ && mainFrame !== null
+ && requestingFrame === mainFrame
+ && !requestingFrame.detached
+ && isTrustedRendererUrl(requestingFrame.url);
+ const resourceCategory = details.resourceType === 'xhr'
+ ? 'xhr'
+ : details.resourceType === 'webSocket'
+ ? 'webSocket'
+ : 'other';
+ const rendererOwned = mainRendererLive
+ && webContentsIdMatches
+ && webContentsAbsentOrMatches
+ && frameMatchesMainFrame;
+ if (details.webContentsId !== undefined) {
+ try {
+ reportRendererOwnershipDecision({
+ schemaVersion: 1,
+ resourceCategory,
+ mainRendererPresent: mainRenderer !== null,
+ mainRendererLive,
+ webContentsIdMatches,
+ webContentsAbsentOrMatches,
+ mainFrameLive,
+ rendererDocumentTrusted,
+ rendererDocumentAuthorityEqual,
+ frameOmitted,
+ framePresent,
+ frameMatchesMainFrame,
+ frameExplicitlyForeign: framePresent && !frameMatchesMainFrame,
+ rendererOwned,
+ });
+ } catch {
+ // Fixed diagnostics cannot alter the renderer ownership decision.
+ }
+ }
+ void credentials.prepareRequestAsync(details.url, details.requestHeaders, {
+ method: details.method,
+ ...(enableRendererNetworkBoundary ? { rendererOwned } : {}),
+ resourceType: details.resourceType,
+ }).then(callback, () => callback({ cancel: true }));
+ });
+ desktopSession.webRequest.onHeadersReceived((details, callback) => {
+ callback({
+ responseHeaders: {
+ ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}),
+ 'Content-Security-Policy': [contentSecurityPolicy()],
+ },
+ });
+ });
+ return {
+ close() {
+ desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true }));
+ desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true }));
+ },
+ dispose() {
+ desktopSession.setPermissionCheckHandler(null);
+ desktopSession.setPermissionRequestHandler(null);
+ desktopSession.webRequest.onBeforeSendHeaders(null);
+ desktopSession.webRequest.onHeadersReceived(null);
+ },
+ };
+};
diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts
index 4a36dcc2e..2fe16b87e 100644
--- a/apps/desktop/src/shared/contract.ts
+++ b/apps/desktop/src/shared/contract.ts
@@ -22,8 +22,17 @@ export const IPC_CHANNELS = Object.freeze({
lifecycleStop: 'desktop:lifecycle-stop',
lifecycleRestart: 'desktop:lifecycle-restart',
deepLink: 'desktop:deep-link',
+ acceptanceJourneyStage: 'desktop:acceptance-journey-stage',
} as const);
+export type DesktopAcceptanceJourneyStage =
+ | 'AUTHENTICATION_REQUIRED'
+ | 'CREDENTIAL_COMMITTED'
+ | 'AUTHENTICATED_REPROBE_READY'
+ | 'ACTIVATION_COMMITTED'
+ | 'ACTIVATION_PUBLISHED'
+ | 'REACT_CONNECTED';
+
export type DesktopPlatform = 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku'
| 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd';
@@ -142,4 +151,8 @@ export interface DesktopBridge {
stop(): Promise;
restart(): Promise;
};
+ /** @internal Present only in an authorized packaged Connect acceptance process. */
+ acceptance?: {
+ reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise;
+ };
}
diff --git a/package.json b/package.json
index 13b25ecc8..8e6a6e4b0 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx",
"test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui",
"pretest:unit": "npm run build -w @propr/shared && npm run build -w @propr/local-setup",
- "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts",
+ "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts",
"test:e2e": "npx tsx --test test/e2e.test.ts",
"test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts",
"test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test",
diff --git a/packages/api/desktopApiBoundary.ts b/packages/api/desktopApiBoundary.ts
new file mode 100644
index 000000000..71a0cf69f
--- /dev/null
+++ b/packages/api/desktopApiBoundary.ts
@@ -0,0 +1,39 @@
+import type { Express, RequestHandler } from 'express';
+import { ensureAuthenticated } from './auth.js';
+import { resolveAuthorization } from './authorization.js';
+import {
+ createDiscoveryRequestRateLimiter,
+ createPairingPollRateLimiter,
+ createPairingStartRateLimiter,
+} from './requestRateLimits.js';
+
+export interface DesktopApiBoundaryRoutes {
+ discovery: RequestHandler;
+ startPairing: RequestHandler;
+ pollPairing: RequestHandler;
+ activatePairing: RequestHandler;
+ cancelPairing: RequestHandler;
+ openPairingApproval: RequestHandler;
+ revokeCurrentToken: RequestHandler;
+}
+
+/**
+ * Register the complete public desktop bootstrap boundary and then close it
+ * with the generic API authentication/authorization guard. Operational routes
+ * must be registered only after this function returns.
+ */
+export function registerDesktopApiBoundary(
+ app: Express,
+ routes: DesktopApiBoundaryRoutes,
+): void {
+ app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), routes.discovery);
+ app.post('/api/desktop/pairings', createPairingStartRateLimiter(), routes.startPairing);
+ app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), routes.pollPairing);
+ app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), routes.activatePairing);
+ app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), routes.cancelPairing);
+ app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), routes.openPairingApproval);
+ // Token possession authorizes only this exact self-revocation route. It must
+ // precede generic auth so inactive tokens receive a stable terminal contract.
+ app.delete('/api/desktop/tokens/current', routes.revokeCurrentToken);
+ app.use('/api', ensureAuthenticated, resolveAuthorization);
+}
diff --git a/packages/api/server.ts b/packages/api/server.ts
index 3e014729c..33561d9e5 100644
--- a/packages/api/server.ts
+++ b/packages/api/server.ts
@@ -6,7 +6,7 @@ import { createClient, RedisClientType } from 'redis';
import { Queue } from 'bullmq';
import 'dotenv/config';
import { Redis, RedisOptions } from 'ioredis';
-import { authenticateSocketRequest, setupAuth, ensureAuthenticated } from './auth.js';
+import { authenticateSocketRequest, setupAuth } from './auth.js';
import { configureDemoMode, createDemoRedisClient, demoModeReadOnlyMiddleware } from './demoMode.js';
import { resolveGithubAuthMode, resolveGithubEventIntakeMode, validateIntakeModePrerequisites } from '@propr/shared';
import { initSocketService, closeSocketService } from './services/socketService.js';
@@ -62,14 +62,12 @@ import { stopTaskExecution } from './routes/dockerRoutes.js';
import { initializePushSubscriptionMaintenance } from './services/pushSubscriptionMaintenance.js';
import { NotificationProjectionService } from './services/notificationProjectionService.js';
import { WebPushDispatcher } from './services/webPushDispatcher.js';
-import { assertInstanceAdministratorConfigured, resolveAuthorization } from './authorization.js';
+import { assertInstanceAdministratorConfigured } from './authorization.js';
import { resolveApiListenHost } from './listenAddress.js';
import {
configureApiProxyTrust,
createApiRequestRateLimiter,
createDiscoveryRequestRateLimiter,
- createPairingPollRateLimiter,
- createPairingStartRateLimiter,
createWebhookRequestRateLimiter,
} from './requestRateLimits.js';
import { desktopAuthService } from './desktopAuthService.js';
@@ -83,6 +81,7 @@ import {
type RouteEntry
} from './routeRegistry.js';
import { createTaskDeleteRouteEntries } from './taskDeleteRouteRegistry.js';
+import { registerDesktopApiBoundary } from './desktopApiBoundary.js';
import {
startVisualPreviewOAuthRefreshScheduler,
type VisualPreviewOAuthRefreshScheduler,
@@ -262,16 +261,15 @@ function setupRoutes(): void {
// They return only compatibility/capability metadata or pairing state gated by
// a high-entropy secret; all operational routes below remain authenticated.
app.get('/api/compatibility', createDiscoveryRequestRateLimiter(), statusRoutes.getCompatibility);
- app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), statusRoutes.getDesktopDiscovery);
- app.post('/api/desktop/pairings', createPairingStartRateLimiter(), desktopAuthRoutes.startPairing);
- app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), desktopAuthRoutes.pollPairing);
- app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), desktopAuthRoutes.activatePairing);
- app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), desktopAuthRoutes.cancelPairing);
- app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), desktopAuthRoutes.openPairingApproval);
- // Token possession authorizes only this exact self-revocation route. It must
- // precede generic auth so inactive tokens receive a stable terminal contract.
- app.delete('/api/desktop/tokens/current', desktopAuthRoutes.revokeCurrentToken);
- app.use('/api', ensureAuthenticated, resolveAuthorization);
+ registerDesktopApiBoundary(app, {
+ discovery: statusRoutes.getDesktopDiscovery,
+ startPairing: desktopAuthRoutes.startPairing,
+ pollPairing: desktopAuthRoutes.pollPairing,
+ activatePairing: desktopAuthRoutes.activatePairing,
+ cancelPairing: desktopAuthRoutes.cancelPairing,
+ openPairingApproval: desktopAuthRoutes.openPairingApproval,
+ revokeCurrentToken: desktopAuthRoutes.revokeCurrentToken,
+ });
app.get('/api/desktop/pairings/:pairingId/approval', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.getPairingApproval);
app.post('/api/desktop/pairings/:pairingId/approve', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.approvalOriginGuard, desktopAuthRoutes.approvePairing);
app.get('/api/desktop/tokens', desktopAuthRoutes.listTokens);
diff --git a/packages/api/test/desktopApiBoundary.test.ts b/packages/api/test/desktopApiBoundary.test.ts
new file mode 100644
index 000000000..fe1dd2811
--- /dev/null
+++ b/packages/api/test/desktopApiBoundary.test.ts
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import type { AddressInfo } from 'node:net';
+import { after, describe, test } from 'node:test';
+import express, { type RequestHandler } from 'express';
+import { closeConnection } from '@propr/core';
+import { registerDesktopApiBoundary, type DesktopApiBoundaryRoutes } from '../desktopApiBoundary.js';
+
+after(async () => closeConnection());
+
+const reached = (name: string): RequestHandler => (_req, res) => {
+ res.status(204).set('X-ProPR-Route', name).end();
+};
+
+const publicRoutes: DesktopApiBoundaryRoutes = {
+ discovery: reached('discovery'),
+ startPairing: reached('start'),
+ pollPairing: reached('poll'),
+ activatePairing: reached('activate'),
+ cancelPairing: reached('cancel'),
+ openPairingApproval: reached('browser'),
+ revokeCurrentToken: reached('revoke'),
+};
+
+const fetchFromApp = async (
+ app: express.Express,
+ path: string,
+ init?: RequestInit,
+): Promise => {
+ const server = app.listen(0, '127.0.0.1');
+ try {
+ await new Promise(resolve => server.once('listening', resolve));
+ const { port } = server.address() as AddressInfo;
+ return await fetch(`http://127.0.0.1:${port}${path}`, init);
+ } finally {
+ await new Promise((resolve, reject) => {
+ server.close(error => error ? reject(error) : resolve());
+ });
+ }
+};
+
+describe('assembled desktop API authentication boundary', () => {
+ test('keeps discovery and bounded pairing bootstrap ahead of the operational API guard', async () => {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.isAuthenticated = () => false;
+ next();
+ });
+ registerDesktopApiBoundary(app, publicRoutes);
+ app.get('/api/status', (_req, res) => res.json({ operational: true }));
+
+ for (const [method, path, expected] of [
+ ['GET', '/api/desktop/discovery', 'discovery'],
+ ['POST', '/api/desktop/pairings', 'start'],
+ ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/poll', 'poll'],
+ ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/activate', 'activate'],
+ ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/cancel', 'cancel'],
+ ['GET', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/browser', 'browser'],
+ ] as const) {
+ const response = await fetchFromApp(app, path, { method });
+ assert.equal(response.status, 204, `${method} ${path}`);
+ assert.equal(response.headers.get('x-propr-route'), expected, `${method} ${path}`);
+ }
+
+ const protectedResponse = await fetchFromApp(app, '/api/status');
+ assert.equal(protectedResponse.status, 401);
+ assert.deepEqual(await protectedResponse.json(), { error: 'Unauthorized' });
+ });
+});
diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts
index 44a95516a..2eb48f10d 100644
--- a/packages/client/src/client.ts
+++ b/packages/client/src/client.ts
@@ -11,7 +11,10 @@ import {
type NormalizeApiBaseUrlOptions,
type ProprApiBaseUrl,
} from './baseUrl.js';
-import { ProprClientError } from './errors.js';
+import {
+ DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED,
+ ProprClientError,
+} from './errors.js';
import {
buildSocketConnection,
connectProprSocket,
@@ -80,6 +83,45 @@ const isCompatibilityMetadata = (value: unknown): value is Partial {
+ let offset = 0;
+ const whitespace = (): void => {
+ while (offset < contents.length && /[\x20\t\r\n]/.test(contents[offset])) offset += 1;
+ };
+ const stringToken = (): string | null => {
+ if (contents[offset] !== '"') return null;
+ const start = offset;
+ offset += 1;
+ while (offset < contents.length) {
+ const character = contents[offset++];
+ if (character === '"') {
+ try { return JSON.parse(contents.slice(start, offset)) as string; } catch { return null; }
+ }
+ if (character === '\\') {
+ const escape = contents[offset++];
+ if (escape === 'u') {
+ if (!/^[0-9a-fA-F]{4}$/.test(contents.slice(offset, offset + 4))) return null;
+ offset += 4;
+ } else if (!escape || !'"\\/bfnrt'.includes(escape)) return null;
+ } else if (character.charCodeAt(0) < 0x20) return null;
+ }
+ return null;
+ };
+
+ whitespace();
+ if (contents[offset++] !== '{') return false;
+ whitespace();
+ if (stringToken() !== 'error') return false;
+ whitespace();
+ if (contents[offset++] !== ':') return false;
+ whitespace();
+ if (stringToken() !== 'Unauthorized') return false;
+ whitespace();
+ if (contents[offset++] !== '}') return false;
+ whitespace();
+ return offset === contents.length;
+};
+
const assertTimeout = (timeoutMs: number): void => {
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new ProprClientError('Request timeouts must be finite, non-negative numbers.', {
@@ -341,11 +383,18 @@ export class ProprClient {
throw new ProprClientError('The ProPR API could not be reached.', { kind: 'network', cause });
}
try {
- if (!response.ok || response.redirected
- || response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
+ const discoveryContentType = response.headers.get('content-type')
+ ?.split(';', 1)[0]?.trim().toLowerCase();
+ const legacyAuthenticationCandidate = response.status === 401
+ && !response.redirected
+ && discoveryContentType === 'application/json';
+ if ((!response.ok && !legacyAuthenticationCandidate)
+ || response.redirected
+ || discoveryContentType !== 'application/json') {
try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ }
throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', {
- kind: 'invalid_response', status: response.status,
+ kind: 'invalid_response',
+ status: response.status,
});
}
const declaredLength = response.headers.get('content-length');
@@ -378,7 +427,8 @@ export class ProprClient {
throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause });
}
throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', {
- kind: 'invalid_response', status: response.status, cause,
+ kind: 'invalid_response', status: response.status,
+ ...(legacyAuthenticationCandidate ? {} : { cause }),
});
} finally { try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } }
const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase();
@@ -395,7 +445,17 @@ export class ProprClient {
try { contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
catch (cause) {
throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', {
- kind: 'invalid_response', status: response.status, cause,
+ kind: 'invalid_response', status: response.status,
+ ...(legacyAuthenticationCandidate ? {} : { cause }),
+ });
+ }
+ if (legacyAuthenticationCandidate) {
+ throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', {
+ kind: 'invalid_response',
+ status: response.status,
+ ...(isExactLegacyDiscoveryAuthenticationBody(contents)
+ ? { code: DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED }
+ : {}),
});
}
const metadata = parseProprDesktopDiscoveryJson(contents);
diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts
index 6a5af7ae1..75ed8fa8b 100644
--- a/packages/client/src/errors.ts
+++ b/packages/client/src/errors.ts
@@ -8,6 +8,10 @@ export type ProprClientErrorKind =
| 'invalid_response'
| 'compatibility';
+/** The exact credential-free public discovery request was authentication-gated. */
+export const DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED =
+ 'DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED' as const;
+
export interface ProprClientErrorOptions {
kind: ProprClientErrorKind;
status?: number;
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 7ff0e4a39..84f5a37ab 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -15,6 +15,7 @@ export {
type ProprRequestOptions,
} from './client.js';
export {
+ DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED,
isProprClientError,
ProprClientError,
type ProprClientErrorKind,
diff --git a/packages/client/src/socket.ts b/packages/client/src/socket.ts
index b59d6f342..b6bdefc50 100644
--- a/packages/client/src/socket.ts
+++ b/packages/client/src/socket.ts
@@ -20,17 +20,43 @@ export interface ProprSocketConnection {
options: ProprSocketOptions;
}
-const bearerSocketAuth = (getAccessToken: AccessTokenProvider): SocketOptions['auth'] =>
- (callback: (data: Record) => void): void => {
+type SocketAuthPayload = Record;
+type SocketAuthCallback = (data: SocketAuthPayload) => void;
+
+const metadataWithoutToken = (value: unknown): SocketAuthPayload => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+ const { token: _untrustedToken, ...metadata } = value as SocketAuthPayload;
+ return metadata;
+};
+
+const bearerSocketAuth = (
+ getAccessToken: AccessTokenProvider,
+ configuredAuth: SocketOptions['auth'],
+): SocketOptions['auth'] => (callback: SocketAuthCallback): void => {
+ const resolveBearer = (metadataValue: unknown): void => {
+ const metadata = metadataWithoutToken(metadataValue);
Promise.resolve(getAccessToken()).then(
token => {
const normalized = token?.trim();
- callback(normalized && !/\r|\n/.test(normalized) ? { token: normalized } : {});
+ callback(normalized && !/\r|\n/.test(normalized)
+ ? { ...metadata, token: normalized }
+ : metadata);
},
- () => callback({})
+ () => callback(metadata),
);
};
+ if (typeof configuredAuth === 'function') {
+ try {
+ configuredAuth(resolveBearer);
+ } catch {
+ resolveBearer({});
+ }
+ return;
+ }
+ resolveBearer(configuredAuth);
+};
+
/** Build the complete, explicit reconnect policy used by every ProPR surface. */
export const buildSocketConnection = (
baseUrl: ProprApiBaseUrl,
@@ -38,7 +64,7 @@ export const buildSocketConnection = (
overrides: ProprSocketOptions = {}
): ProprSocketConnection => {
const auth = authentication.type === 'bearer'
- ? bearerSocketAuth(authentication.getAccessToken)
+ ? bearerSocketAuth(authentication.getAccessToken, overrides.auth)
: undefined;
return {
@@ -55,7 +81,7 @@ export const buildSocketConnection = (
randomizationFactor: 0.5,
timeout: 20_000,
...overrides,
- ...(auth && overrides.auth === undefined ? { auth } : {}),
+ ...(auth ? { auth } : {}),
},
};
};
diff --git a/packages/client/test/desktopPairing.test.ts b/packages/client/test/desktopPairing.test.ts
index e803774ba..cdd59a678 100644
--- a/packages/client/test/desktopPairing.test.ts
+++ b/packages/client/test/desktopPairing.test.ts
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared';
-import { ProprClient, ProprClientError } from '../src/index.js';
+import {
+ DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED,
+ ProprClient,
+ ProprClientError,
+} from '../src/index.js';
const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), {
status,
@@ -74,6 +78,100 @@ class PairingClock {
}
describe('desktop instance protocol', () => {
+ it('strictly classifies only the credential-free public discovery 401', async () => {
+ const legacy = new ProprClient({
+ baseUrl: 'https://propr.example.test',
+ authentication: { type: 'none' },
+ fetch: async (_input, init) => {
+ assert.equal(init?.credentials, 'omit');
+ assert.equal(init?.redirect, 'manual');
+ return new Response('{ "error": "Unauthorized" }', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ });
+ },
+ });
+ await assert.rejects(legacy.discoverDesktop(), (error: unknown) =>
+ error instanceof ProprClientError
+ && error.kind === 'invalid_response'
+ && error.status === 401
+ && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED);
+
+ const oversized = `{"error":"Unauthorized","padding":"${'x'.repeat(8 * 1024)}"}`;
+ const invalidResponses = [
+ new Response(null, { status: 401, headers: { 'Content-Type': 'application/json' } }),
+ new Response('Policy login required
', {
+ status: 401, headers: { 'Content-Type': 'text/html' },
+ }),
+ new Response('{"error":', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"error":"Unauthorized","error":"Unauthorized"}', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"error":"Unauthorized","code":"PROXY_POLICY"}', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"code":"AUTHENTICATION_REQUIRED"}', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"error":"private proxy policy detail"}', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"error":"Unauthorized"}', {
+ status: 401,
+ headers: { 'Content-Type': 'application/json', 'Content-Length': '8193' },
+ }),
+ new Response(oversized, {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d]), {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ }),
+ new Response('{"error":"Unauthorized"}', {
+ status: 401,
+ headers: { 'Content-Type': 'application/json', 'Content-Length': '1' },
+ }),
+ new Response('{"error":"Unauthorized"}', {
+ status: 401, headers: { 'Content-Type': 'application/problem+json' },
+ }),
+ ];
+ const redirected = new Response('{"error":"Unauthorized"}', {
+ status: 401, headers: { 'Content-Type': 'application/json' },
+ });
+ Object.defineProperty(redirected, 'redirected', { value: true });
+ invalidResponses.push(redirected);
+
+ for (const response of invalidResponses) {
+ const client = new ProprClient({
+ baseUrl: 'https://propr.example.test',
+ authentication: { type: 'none' },
+ fetch: async () => response,
+ });
+ await assert.rejects(client.discoverDesktop(), (error: unknown) => {
+ assert.ok(error instanceof ProprClientError);
+ assert.equal(error.kind, 'invalid_response');
+ assert.equal(error.status, 401);
+ assert.equal(error.code, undefined);
+ assert.equal(error.body, undefined);
+ assert.equal(error.cause, undefined);
+ assert.doesNotMatch(JSON.stringify(error), /private proxy policy detail|Unauthorized/u);
+ return true;
+ });
+ }
+
+ const operational = new ProprClient({
+ baseUrl: 'https://propr.example.test',
+ authentication: { type: 'none' },
+ fetch: async () => json({ code: 'AUTHENTICATION_REQUIRED' }, 401),
+ });
+ await assert.rejects(operational.request('/api/tasks'), (error: unknown) =>
+ error instanceof ProprClientError
+ && error.kind === 'http'
+ && error.status === 401
+ && error.code === 'AUTHENTICATION_REQUIRED');
+
+ });
+
it('uses the shared strict wire parser for missing, extra, malformed, duplicate, and oversized discovery', async () => {
const valid = JSON.stringify(discovery);
const invalidBodies = [
@@ -126,10 +224,10 @@ describe('desktop instance protocol', () => {
authentication: { type: 'none' },
fetch: async () => new Response(new ReadableStream({
start(controller) {
- controller.enqueue(new TextEncoder().encode('{"schemaVersion":1'));
+ controller.enqueue(new TextEncoder().encode('{"error":"Unauthor'));
},
cancel() { bodyCancelled += 1; },
- }), { headers: { 'Content-Type': 'application/json' } }),
+ }), { status: 401, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(bounded(stalledBody.discoverDesktop(20), 500), (error: unknown) =>
error instanceof ProprClientError && error.kind === 'timeout');
diff --git a/packages/client/test/socket.test.ts b/packages/client/test/socket.test.ts
index 6347db87d..c970bc096 100644
--- a/packages/client/test/socket.test.ts
+++ b/packages/client/test/socket.test.ts
@@ -38,6 +38,28 @@ describe('Socket.IO connection configuration', () => {
assert.deepEqual(await resolveAuth(), { token: 'refreshed-token' });
});
+ it('preserves handshake metadata while only the fresh provider can supply the bearer token', async () => {
+ let token = 'first-token';
+ const connection = buildSocketConnection(
+ normalizeApiBaseUrl('https://propr.example.com'),
+ { type: 'bearer', getAccessToken: () => token },
+ { auth: { proprDesktopTransportScope: 'scope-a', token: 'metadata-token' } }
+ );
+
+ const resolveAuth = (): Promise => new Promise(resolve => {
+ (connection.options.auth as (callback: (data: unknown) => void) => void)(resolve);
+ });
+ assert.deepEqual(await resolveAuth(), {
+ proprDesktopTransportScope: 'scope-a',
+ token: 'first-token',
+ });
+ token = 'refreshed-token';
+ assert.deepEqual(await resolveAuth(), {
+ proprDesktopTransportScope: 'scope-a',
+ token: 'refreshed-token',
+ });
+ });
+
it('routes Connect Socket.IO to the same origin and fixed proxy path', () => {
const connection = buildSocketConnection(
normalizeApiBaseUrl('https://t-instance123.propr.dev'),
diff --git a/propr-ui/src/contexts/SocketProvider.test.tsx b/propr-ui/src/contexts/SocketProvider.test.tsx
index 4dfb5091f..ee96e4ab6 100644
--- a/propr-ui/src/contexts/SocketProvider.test.tsx
+++ b/propr-ui/src/contexts/SocketProvider.test.tsx
@@ -88,6 +88,7 @@ describe('SocketProvider', () => {
expect(connectSocketMock).toHaveBeenCalledOnce();
expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({
forceNew: true,
+ auth: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' },
query: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' },
}));
});
@@ -159,6 +160,7 @@ describe('SocketProvider', () => {
expect(connectSocketMock).toHaveBeenCalledOnce();
expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ forceNew: true }));
+ expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ auth: expect.anything() }));
expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ query: expect.anything() }));
});
diff --git a/propr-ui/src/contexts/SocketProvider.tsx b/propr-ui/src/contexts/SocketProvider.tsx
index 9bf0d9956..0c6ef8459 100644
--- a/propr-ui/src/contexts/SocketProvider.tsx
+++ b/propr-ui/src/contexts/SocketProvider.tsx
@@ -49,7 +49,10 @@ export const SocketProvider: React.FC = ({ children, disabl
autoConnect: true,
path: '/socket.io/',
forceNew: true,
- ...(desktopScope ? { query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope } } : {}),
+ ...(desktopScope ? {
+ auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope },
+ query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope },
+ } : {}),
});
let disposed = false;
const isCurrentScope = (): boolean => {
diff --git a/propr-ui/src/desktop/DesktopConnectedExperience.tsx b/propr-ui/src/desktop/DesktopConnectedExperience.tsx
new file mode 100644
index 000000000..8d81ca948
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopConnectedExperience.tsx
@@ -0,0 +1,87 @@
+import React, { useEffect, useState } from 'react';
+import type { RefObject } from 'react';
+import { Plus, X } from 'lucide-react';
+import { DesktopContext } from './DesktopContext';
+import { ProfileEditor, ProfileList } from './DesktopExperiencePanels';
+import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+
+interface DesktopConnectedExperienceProps {
+ adapters: DesktopAdapters;
+ profile: DesktopProfile;
+ result: Extract;
+ profiles: DesktopProfile[];
+ managerOpen: boolean;
+ managerRef: RefObject;
+ editing: DesktopProfile | 'new' | null;
+ operationError: string | null;
+ deepLinkError: string | null;
+ editorNotice: string | null;
+ hasPendingConnectCandidate: boolean;
+ children: React.ReactNode;
+ openManager(): void;
+ closeManager(): void;
+ closeEditor(): void;
+ openEditor(profile: DesktopProfile | 'new'): void;
+ connect(profile: DesktopProfile): Promise;
+ removeProfile(profile: DesktopProfile): Promise;
+ saveProfile(profile: DesktopProfile, shouldConnect?: boolean): Promise;
+ retry(): void;
+ setManagerOpen(open: boolean): void;
+}
+
+export const DesktopConnectedExperience: React.FC = ({
+ adapters, profile, result, profiles, managerOpen, managerRef, editing,
+ operationError, deepLinkError, editorNotice, hasPendingConnectCandidate,
+ children, openManager, closeManager, closeEditor, openEditor, connect,
+ removeProfile, saveProfile, retry, setManagerOpen,
+}) => {
+ const [networkOffline, setNetworkOffline] = useState(!navigator.onLine);
+
+ useEffect(() => {
+ const online = () => setNetworkOffline(false);
+ const offline = () => setNetworkOffline(true);
+ window.addEventListener('online', online);
+ window.addEventListener('offline', offline);
+ return () => {
+ window.removeEventListener('online', online);
+ window.removeEventListener('offline', offline);
+ };
+ }, []);
+
+ const displayedConnection: DesktopConnectionResult = networkOffline
+ ? { status: 'offline', message: 'This computer is offline.' }
+ : result;
+ const contextValue = {
+ isDesktop: true as const,
+ platform: adapters.platform,
+ profile,
+ connection: displayedConnection,
+ openProfileManager: openManager,
+ authenticate: () => adapters.authentication.authenticate(profile),
+ openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'),
+ retry,
+ };
+
+ return (
+
+ {deepLinkError && {deepLinkError}
}
+ {children}
+ {managerOpen && (
+ { if (event.target === event.currentTarget) closeManager(); }}>
+
+
+ {editing ? (
+ void saveProfile(editedProfile, hasPendingConnectCandidate || editing === 'new' || profile.id === editedProfile.id)} />
+ ) : (
+ <>
+ {operationError && {operationError}
}
+ { setManagerOpen(false); void connect(nextProfile); }} onEdit={openEditor} onRemove={nextProfile => void removeProfile(nextProfile)} />
+
+ >
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx
new file mode 100644
index 000000000..fae4e5f35
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { DesktopExperience } from './DesktopExperience';
+import { adaptersFor, remoteProfile } from './DesktopExperience.testSupport';
+
+const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() }));
+const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() }));
+
+vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl }));
+vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl }));
+
+describe('DesktopExperience authentication', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('reconnects after authentication completes and advances to the connected app', async () => {
+ const probe = vi.fn()
+ .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' })
+ .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' });
+ const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe);
+ const stages: string[] = [];
+ adapters.acceptance = {
+ reportJourneyStage: vi.fn(async stage => { stages.push(stage); }),
+ };
+ render(Connected app
);
+
+ fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i }));
+
+ expect(await screen.findByText('Connected app')).toBeInTheDocument();
+ expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile);
+ expect(probe).toHaveBeenCalledTimes(2);
+ await waitFor(() => expect(stages).toEqual([
+ 'AUTHENTICATION_REQUIRED',
+ 'CREDENTIAL_COMMITTED',
+ 'AUTHENTICATED_REPROBE_READY',
+ 'ACTIVATION_COMMITTED',
+ 'ACTIVATION_PUBLISHED',
+ 'REACT_CONNECTED',
+ ]));
+ });
+
+ it('reports rejected authentication and connection-help operations in the blocked panel', async () => {
+ const adapters = adaptersFor(
+ [remoteProfile],
+ remoteProfile.id,
+ async () => ({ status: 'authentication-required', message: 'Please sign in.' })
+ );
+ vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.'));
+ vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.'));
+ render(Connected app
);
+
+ fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i }));
+ expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument();
+ expect(screen.queryByText(/browser launch failed/i)).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /Open connection help/i }));
+ expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument();
+ expect(screen.queryByText(/no browser is configured/i)).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument();
+ });
+});
diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx
index ea7a818d9..f5545dcb8 100644
--- a/propr-ui/src/desktop/DesktopExperience.test.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.test.tsx
@@ -2,8 +2,8 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesktopDeepLinkInbox } from '../desktop-deep-link';
import { DesktopExperience } from './DesktopExperience';
-import { DesktopTitleBar } from './DesktopTitleBar';
-import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+import { adaptersFor, deferred, localProfile, remoteProfile, renderConnectedExperience } from './DesktopExperience.testSupport';
+import type { DesktopConnectionResult } from './types';
const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() }));
const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() }));
@@ -11,54 +11,6 @@ const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() }));
vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl }));
vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl }));
-const localProfile: DesktopProfile = {
- id: 'local',
- name: 'This computer',
- baseUrl: 'http://127.0.0.1:3000',
- kind: 'local',
-};
-
-const remoteProfile: DesktopProfile = {
- id: 'remote',
- name: 'Team server',
- baseUrl: 'https://propr.example.com',
- kind: 'remote',
-};
-
-const adaptersFor = (
- profiles: DesktopProfile[] = [],
- activeId: string | null = null,
- probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' })
-): DesktopAdapters => ({
- platform: 'linux',
- app: { onDeepLink: () => () => undefined },
- profiles: {
- list: vi.fn(async () => profiles),
- save: vi.fn(async () => undefined),
- remove: vi.fn(async () => undefined),
- getActiveId: vi.fn(async () => activeId),
- setActiveId: vi.fn(async () => undefined),
- },
- discovery: { supported: true, discover: vi.fn(async () => []) },
- authentication: { authenticate: vi.fn(async () => undefined) },
- externalBrowser: { open: vi.fn(async () => undefined) },
- localSetup: { supported: true, setup: vi.fn(async () => localProfile) },
- connection: { probe: vi.fn(probe) },
-});
-
-function deferred() {
- let resolve!: (value: T) => void;
- const promise = new Promise(complete => { resolve = complete; });
- return { promise, resolve };
-}
-
-const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render(
-
-
- {content && {content}
}
-
-);
-
describe('DesktopExperience', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -419,41 +371,6 @@ describe('DesktopExperience', () => {
expect(adapters.profiles.remove).toHaveBeenCalledWith(remoteProfile.id);
});
- it('reconnects after authentication completes and advances to the connected app', async () => {
- const probe = vi.fn()
- .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' })
- .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' });
- const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe);
- render(Connected app
);
-
- fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i }));
-
- expect(await screen.findByText('Connected app')).toBeInTheDocument();
- expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile);
- expect(probe).toHaveBeenCalledTimes(2);
- });
-
- it('reports rejected authentication and connection-help operations in the blocked panel', async () => {
- const adapters = adaptersFor(
- [remoteProfile],
- remoteProfile.id,
- async () => ({ status: 'authentication-required', message: 'Please sign in.' })
- );
- vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.'));
- vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.'));
- render(Connected app
);
-
- fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i }));
- expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument();
- expect(screen.queryByText(/browser launch failed/i)).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: /Open connection help/i }));
- expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument();
- expect(screen.queryByText(/no browser is configured/i)).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument();
- });
-
it.each(['macos', 'windows'] as const)('offers remote connection guidance instead of local setup on %s', async platform => {
const adapters = adaptersFor();
adapters.platform = platform;
diff --git a/propr-ui/src/desktop/DesktopExperience.testSupport.tsx b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx
new file mode 100644
index 000000000..193019a20
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx
@@ -0,0 +1,53 @@
+import { render } from '@testing-library/react';
+import { vi } from 'vitest';
+import { DesktopExperience } from './DesktopExperience';
+import { DesktopTitleBar } from './DesktopTitleBar';
+import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+
+export const localProfile: DesktopProfile = {
+ id: 'local',
+ name: 'This computer',
+ baseUrl: 'http://127.0.0.1:3000',
+ kind: 'local',
+};
+
+export const remoteProfile: DesktopProfile = {
+ id: 'remote',
+ name: 'Team server',
+ baseUrl: 'https://propr.example.com',
+ kind: 'remote',
+};
+
+export const adaptersFor = (
+ profiles: DesktopProfile[] = [],
+ activeId: string | null = null,
+ probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' })
+): DesktopAdapters => ({
+ platform: 'linux',
+ app: { onDeepLink: () => () => undefined },
+ profiles: {
+ list: vi.fn(async () => profiles),
+ save: vi.fn(async () => undefined),
+ remove: vi.fn(async () => undefined),
+ getActiveId: vi.fn(async () => activeId),
+ setActiveId: vi.fn(async () => undefined),
+ },
+ discovery: { supported: true, discover: vi.fn(async () => []) },
+ authentication: { authenticate: vi.fn(async () => undefined) },
+ externalBrowser: { open: vi.fn(async () => undefined) },
+ localSetup: { supported: true, setup: vi.fn(async () => localProfile) },
+ connection: { probe: vi.fn(probe) },
+});
+
+export function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(complete => { resolve = complete; });
+ return { promise, resolve };
+}
+
+export const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render(
+
+
+ {content && {content}
}
+
+);
diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx
index df1c64989..92c0e9462 100644
--- a/propr-ui/src/desktop/DesktopExperience.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.tsx
@@ -1,48 +1,24 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { parseProprConnectEndpoint } from '@propr/shared';
-import { LoaderCircle, Plus, X } from 'lucide-react';
+import { LoaderCircle } from 'lucide-react';
import { setApiBaseUrl } from '../api/apiClient';
import * as runtimeConfig from '../config/runtimeConfig';
import type { DesktopDeepLinkInbox } from '../desktop-deep-link';
-import { DesktopContext } from './DesktopContext';
+import { DesktopConnectedExperience } from './DesktopConnectedExperience';
import { useAttemptFence, useDesktopModal, useSerializedMutationQueue } from './desktopExperienceHooks';
-import { ConnectionPanel, DesktopBrand, InstanceChooser, ManagedRecoveryReview, ProfileEditor, ProfileList } from './DesktopExperiencePanels';
+import { ConnectionPanel, DesktopBrand, InstanceChooser, ManagedRecoveryReview, ProfileEditor } from './DesktopExperiencePanels';
import { managedRecoveryMessage, managedRediscoveryUnavailableMessage, safeConnectionMessage } from './desktopExperienceMessages';
+import { mergeProfiles, recoverableError, settleAuthenticationCancellation, type ExperienceState } from './desktopExperienceState';
import { DESKTOP_ACCESS_INVALID_EVENT, type DesktopAccessInvalidEventDetail, type DesktopAdapters, type DesktopConnectionResult, type DesktopProfile } from './types';
import { useDesktopDeepLinks } from './useDesktopDeepLinks';
import './desktop.css';
-type ExperienceState =
- | { phase: 'loading' }
- | { phase: 'choose' }
- | { phase: 'connecting'; profile: DesktopProfile }
- | { phase: 'blocked'; profile: DesktopProfile; result: Exclude }
- | { phase: 'recovery-review'; profile: DesktopProfile; candidate: DesktopProfile }
- | { phase: 'connected'; profile: DesktopProfile; result: Extract };
-
interface DesktopExperienceProps {
adapters: DesktopAdapters;
deepLinks?: DesktopDeepLinkInbox;
children: React.ReactNode;
}
-const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => {
- const profiles = new Map(current.map(profile => [profile.id, profile]));
- incoming.forEach(profile => profiles.set(profile.id, profile));
- return [...profiles.values()].sort((a, b) =>
- (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || ''));
-};
-
-const recoverableError = (message: string): string => `${message} Try again.`;
-
-const settleAuthenticationCancellation = (adapters: DesktopAdapters, profileId: string): void => {
- // Back/navigation must remain synchronous. Cancellation is best effort and
- // its rejection is deliberately consumed so shutdown cannot create an
- // unhandled promise containing host-specific IPC details.
- void Promise.resolve()
- .then(() => adapters.authentication.cancel?.(profileId))
- .catch(() => undefined);
-};
export const DesktopExperience: React.FC = ({ adapters, deepLinks, children }) => {
const [profiles, setProfiles] = useState([]);
const [state, setState] = useState({ phase: 'loading' });
@@ -50,7 +26,6 @@ export const DesktopExperience: React.FC = ({ adapters,
const [managerOpen, setManagerOpen] = useState(false);
const [operationError, setOperationError] = useState(null);
const [busy, setBusy] = useState(false);
- const [networkOffline, setNetworkOffline] = useState(!navigator.onLine);
const connectionAttempt = useRef(0);
const activeProfileId = useRef(null);
const stateRef = useRef(state);
@@ -90,6 +65,15 @@ export const DesktopExperience: React.FC = ({ adapters,
setEditing(null);
}, [cancelDiscovery, clearConnectCandidate]);
const { dialogRef: managerRef, openModal: openManager } = useDesktopModal(managerOpen, setManagerOpen, closeManager);
+ const reportAcceptanceStage = useCallback(async (
+ stage: Parameters['reportJourneyStage']>[0],
+ ): Promise => {
+ try {
+ await adapters.acceptance?.reportJourneyStage(stage);
+ } catch {
+ // Acceptance diagnostics must never alter the renderer lifecycle they observe.
+ }
+ }, [adapters]);
const connect = useCallback(async (profile: DesktopProfile) => {
cancelDiscovery();
@@ -102,6 +86,9 @@ export const DesktopExperience: React.FC = ({ adapters,
const probeResult = await adapters.connection.probe(profile);
if (!isCurrentAttempt()) return;
if (probeResult.status !== 'ready') {
+ if (probeResult.status === 'authentication-required') {
+ await reportAcceptanceStage('AUTHENTICATION_REQUIRED');
+ }
setState({
phase: 'blocked',
profile,
@@ -109,6 +96,7 @@ export const DesktopExperience: React.FC = ({ adapters,
});
return;
}
+ await reportAcceptanceStage('AUTHENTICATED_REPROBE_READY');
operation = 'persist';
const connectedProfile = { ...profile, lastConnectedAt: new Date().toISOString() };
@@ -121,7 +109,10 @@ export const DesktopExperience: React.FC = ({ adapters,
result = await adapters.connection.activate(connectedProfile, probeResult, isCurrentAttempt);
}
else if (activeProfileId.current !== profile.id) await adapters.profiles.setActiveId(profile.id);
- if (result.status === 'ready') activeProfileId.current = profile.id;
+ if (result.status === 'ready') {
+ activeProfileId.current = profile.id;
+ await reportAcceptanceStage('ACTIVATION_COMMITTED');
+ }
});
if (!isCurrentAttempt()) return;
setProfiles(current => mergeProfiles(current, [connectedProfile]));
@@ -132,6 +123,7 @@ export const DesktopExperience: React.FC = ({ adapters,
runtimeConfig.setDesktopApiBaseUrl(connectedProfile.baseUrl);
if (adapters.connection.publishActivation) adapters.connection.publishActivation(connectedProfile, result);
else setApiBaseUrl(connectedProfile.baseUrl);
+ await reportAcceptanceStage('ACTIVATION_PUBLISHED');
setState({ phase: 'connected', profile: connectedProfile, result });
} catch {
if (!isCurrentAttempt()) return;
@@ -140,7 +132,11 @@ export const DesktopExperience: React.FC = ({ adapters,
: 'ProPR Desktop could not check this instance. Try again.';
setState({ phase: 'blocked', profile, result: { status: 'offline', message } });
}
- }, [adapters, cancelDiscovery, enqueueProfileMutation]);
+ }, [adapters, cancelDiscovery, enqueueProfileMutation, reportAcceptanceStage]);
+
+ useEffect(() => {
+ if (state.phase === 'connected') void reportAcceptanceStage('REACT_CONNECTED');
+ }, [reportAcceptanceStage, state.phase]);
useEffect(() => {
let cancelled = false;
@@ -189,14 +185,6 @@ export const DesktopExperience: React.FC = ({ adapters,
return () => window.removeEventListener(DESKTOP_ACCESS_INVALID_EVENT, accessInvalid);
}, [adapters]);
- useEffect(() => {
- const online = () => setNetworkOffline(false); const offline = () => setNetworkOffline(true);
- window.addEventListener('online', online); window.addEventListener('offline', offline);
- return () => {
- window.removeEventListener('online', online); window.removeEventListener('offline', offline);
- };
- }, []);
-
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
const current = stateRef.current;
@@ -387,43 +375,25 @@ export const DesktopExperience: React.FC = ({ adapters,
if (state.phase === 'loading') return Opening ProPR…
;
if (state.phase === 'connecting') return undefined} onHelp={() => undefined} onReenter={() => undefined} onRediscover={() => undefined} />;
if (state.phase === 'recovery-review') return { cancelDiscovery(); setState({ phase: 'blocked', profile: state.profile, result: { status: 'offline', message: managedRecoveryMessage } }); }} onConfirm={() => void connect(state.candidate)} />;
- if (state.phase === 'blocked') return void runBlockedAction(state.profile, () => adapters.authentication.authenticate(state.profile), 'ProPR Desktop could not open sign in.', 'ProPR Connect pairing could not be completed.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} onReenter={() => reenterManagedEndpoint(state.profile)} onRediscover={() => void rediscoverManagedEndpoint(state.profile)} />;
+ if (state.phase === 'blocked') return void runBlockedAction(state.profile, async () => {
+ await adapters.authentication.authenticate(state.profile);
+ await reportAcceptanceStage('CREDENTIAL_COMMITTED');
+ }, 'ProPR Desktop could not open sign in.', 'ProPR Connect pairing could not be completed.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} onReenter={() => reenterManagedEndpoint(state.profile)} onRediscover={() => void rediscoverManagedEndpoint(state.profile)} />;
if (editing) return void saveProfile(profile)} />;
return void setupLocal()} onConnectNew={() => openEditor('new')} onDiscover={() => void discover()} onConnect={profile => void connect(profile)} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} />;
};
if (state.phase !== 'connected') return {deepLinkError &&
{deepLinkError}
}{content()}
;
- const displayedConnection: DesktopConnectionResult = networkOffline ? { status: 'offline', message: 'This computer is offline.' } : state.result;
- const contextValue = {
- isDesktop: true as const, platform: adapters.platform, profile: state.profile,
- connection: displayedConnection,
- openProfileManager: openManager,
- authenticate: () => adapters.authentication.authenticate(state.profile),
- openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'),
- retry,
- };
-
return (
-
- {deepLinkError && {deepLinkError}
}
- {children}
- {managerOpen && (
- { if (event.target === event.currentTarget) closeManager(); }}>
-
-
- {editing ? (
- void saveProfile(profile, hasPendingConnectCandidate() || editing === 'new' || state.profile.id === profile.id)} />
- ) : (
- <>
- {operationError && {operationError}
}
- { setManagerOpen(false); void connect(profile); }} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} />
-
- >
- )}
-
-
- )}
-
+ {children}
);
};
diff --git a/propr-ui/src/desktop/desktopExperienceState.ts b/propr-ui/src/desktop/desktopExperienceState.ts
new file mode 100644
index 000000000..cc13dd001
--- /dev/null
+++ b/propr-ui/src/desktop/desktopExperienceState.ts
@@ -0,0 +1,27 @@
+import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+
+export type ExperienceState =
+ | { phase: 'loading' }
+ | { phase: 'choose' }
+ | { phase: 'connecting'; profile: DesktopProfile }
+ | { phase: 'blocked'; profile: DesktopProfile; result: Exclude }
+ | { phase: 'recovery-review'; profile: DesktopProfile; candidate: DesktopProfile }
+ | { phase: 'connected'; profile: DesktopProfile; result: Extract };
+
+export const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => {
+ const profiles = new Map(current.map(profile => [profile.id, profile]));
+ incoming.forEach(profile => profiles.set(profile.id, profile));
+ return [...profiles.values()].sort((a, b) =>
+ (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || ''));
+};
+
+export const recoverableError = (message: string): string => `${message} Try again.`;
+
+export const settleAuthenticationCancellation = (adapters: DesktopAdapters, profileId: string): void => {
+ // Back/navigation must remain synchronous. Cancellation is best effort and
+ // its rejection is deliberately consumed so shutdown cannot create an
+ // unhandled promise containing host-specific IPC details.
+ void Promise.resolve()
+ .then(() => adapters.authentication.cancel?.(profileId))
+ .catch(() => undefined);
+};
diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts
index 9a618facf..f98cc53ea 100644
--- a/propr-ui/src/desktop/electronAdapters.ts
+++ b/propr-ui/src/desktop/electronAdapters.ts
@@ -122,6 +122,11 @@ export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAda
return fromDiscoveryCandidate(candidate);
},
},
+ ...(bridge.acceptance ? {
+ acceptance: {
+ reportJourneyStage: stage => bridge.acceptance!.reportJourneyStage(stage),
+ },
+ } : {}),
authentication: {
async authenticate(profile) {
const security = await bridge.storage.security();
diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts
index f58e9cd3d..c6b3f3cbe 100644
--- a/propr-ui/src/desktop/types.ts
+++ b/propr-ui/src/desktop/types.ts
@@ -71,6 +71,14 @@ export interface DesktopConnectionAdapter {
deactivate?(): void;
}
+export type DesktopAcceptanceJourneyStage =
+ | 'AUTHENTICATION_REQUIRED'
+ | 'CREDENTIAL_COMMITTED'
+ | 'AUTHENTICATED_REPROBE_READY'
+ | 'ACTIVATION_COMMITTED'
+ | 'ACTIVATION_PUBLISHED'
+ | 'REACT_CONNECTED';
+
export interface DesktopManagedTunnelRecoveryAdapter {
/**
* Request a secret-free Connect endpoint refresh for an existing profile.
@@ -92,6 +100,10 @@ export interface DesktopAdapters {
localSetup: DesktopLocalSetupAdapter;
connection: DesktopConnectionAdapter;
managedTunnelRecovery?: DesktopManagedTunnelRecoveryAdapter;
+ /** @internal Authorized packaged-journey evidence; absent in production use. */
+ acceptance?: {
+ reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise;
+ };
}
/**
diff --git a/test/config-followup.test.ts b/test/config-followup.test.ts
index a4d008e94..fe48af4be 100644
--- a/test/config-followup.test.ts
+++ b/test/config-followup.test.ts
@@ -64,7 +64,7 @@ test('saveSettingsWithRollback returns a specific failure without partial-commit
database: testDb,
settings: {
planner_context_model: 'gpt-5',
- pr_review_model: 'claude-sonnet-4-6'
+ pr_review_model: ''
},
publishConfigUpdate: async () => {
published += 1;