Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions .github/workflows/desktop-connect-discovery-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/scripts/packaged-connect-launch.mjs
Original file line number Diff line number Diff line change
@@ -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);
55 changes: 55 additions & 0 deletions apps/desktop/scripts/packaged-connect-launch.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
213 changes: 194 additions & 19 deletions apps/desktop/scripts/packaged-connect-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading