diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index 46cdbe60eb..74ce56dc60 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -135,7 +135,7 @@ jobs: exit 1 fi - echo "tag_commit=$TAG_COMMIT" >> $GITHUB_OUTPUT + echo "tag_commit=$TAG_COMMIT" >> "$GITHUB_OUTPUT" echo "Validated $TAG at $TAG_COMMIT against ${EVAOS_BETA_RELEASE_BRANCH}." - name: Download release assets @@ -266,14 +266,38 @@ jobs: echo "::error::Fallback release did not provide a ZIP or DMG with pattern $FALLBACK_PATTERN." exit 1 fi - echo "FALLBACK_ASSET=$FALLBACK_ASSET" >> $GITHUB_ENV + echo "FALLBACK_ASSET=$FALLBACK_ASSET" >> "$GITHUB_ENV" ls -la fallback-assets - name: Prepare RC proof directory + id: prepare_proof env: TAG: ${{ github.event.inputs.tag }} run: | set -euo pipefail + { + echo "RC_PHASE=prepare_proof" + echo "RC_CONNECTOR_START_INVOKED=false" + echo "RC_CONNECTOR_PROCESS_RUNNING=false" + echo "RC_CONNECTOR_PROCESS_EXIT_CODE=" + echo "RC_CONNECTOR_ATTEMPTS=0" + echo "RC_CONNECTOR_TOKEN_EXISTS=false" + echo "RC_CONNECTOR_TOKEN_REGULAR=false" + echo "RC_CONNECTOR_TOKEN_OWNER_MATCH=false" + echo "RC_CONNECTOR_TOKEN_MODE_600=false" + echo "RC_CONNECTOR_TOKEN_NONEMPTY=false" + echo "RC_CONNECTOR_HEALTH_REACHABLE=false" + echo "RC_CONNECTOR_READINESS_CLASSIFICATION=not_started" + echo "RC_QA_STARTED=false" + echo "RC_QA_SUCCEEDED=false" + echo "RC_CONNECTOR_CLEANUP_ATTEMPTED=false" + echo "RC_CONNECTOR_CLEANUP_SUCCEEDED=false" + echo "RC_WORKBENCH_CLEANUP_ATTEMPTED=false" + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=false" + echo "RC_FALLBACK_LAUNCH_VERIFIED=false" + echo "RC_ROLLBACK_ATTEMPTED=false" + echo "RC_ROLLBACK_SUCCEEDED=false" + } >> "$GITHUB_ENV" rm -rf "$PROOF_DIR" mkdir -p "$PROOF_DIR/release-assets" "$PROOF_DIR/trusted-manifest" cp release-assets/evaos-beta-release-manifest.json "$PROOF_DIR/release-assets/evaos-beta-release-manifest.json" @@ -305,6 +329,7 @@ jobs: TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} run: | set -euo pipefail + echo "RC_PHASE=verify_updater_zip" >> "$GITHUB_ENV" ZIP_NAME_PATH="$RUNNER_TEMP/evaos-updater-zip-name.txt" node - release-assets/latest-arm64-mac.yml "$ZIP_NAME_PATH" <<'NODE' const fs = require('fs'); @@ -410,10 +435,12 @@ jobs: rm -rf "$EXTRACT_DIR" - name: Install fallback and beta apps + id: install_apps env: FALLBACK_APP_NAME: ${{ github.event.inputs.fallback_app_name }} run: | set -euo pipefail + echo "RC_PHASE=install_candidate" >> "$GITHUB_ENV" install_app_from_dmg() { local dmg="$1" local app_name="$2" @@ -475,8 +502,35 @@ jobs: esac } + echo "mutation_started=true" >> "$GITHUB_OUTPUT" install_fallback_app "$FALLBACK_ASSET" "$FALLBACK_APP_NAME" + FALLBACK_PLIST="/Applications/$FALLBACK_APP_NAME/Contents/Info.plist" + if [ ! -f "$FALLBACK_PLIST" ]; then + echo "::error::Installed fallback app is missing Info.plist." + exit 1 + fi + FALLBACK_EXPECTED_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$FALLBACK_PLIST") + FALLBACK_EXPECTED_PRODUCT_NAME=$(/usr/libexec/PlistBuddy -c 'Print CFBundleName' "$FALLBACK_PLIST") + FALLBACK_EXPECTED_SHORT_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$FALLBACK_PLIST") + FALLBACK_EXPECTED_BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$FALLBACK_PLIST") + FALLBACK_EXPECTED_EXECUTABLE=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$FALLBACK_PLIST") + if [[ ! "$FALLBACK_EXPECTED_BUNDLE_ID" =~ ^[A-Za-z0-9.-]+$ ]] || \ + [[ ! "$FALLBACK_EXPECTED_PRODUCT_NAME" =~ ^[A-Za-z0-9._\ -]+$ ]] || \ + [[ ! "$FALLBACK_EXPECTED_SHORT_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || \ + [[ ! "$FALLBACK_EXPECTED_BUNDLE_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || \ + [[ ! "$FALLBACK_EXPECTED_EXECUTABLE" =~ ^[A-Za-z0-9._\ -]+$ ]]; then + echo "::error::Installed fallback app has unsafe or incomplete bundle identity metadata." + exit 1 + fi + { + echo "FALLBACK_EXPECTED_BUNDLE_ID=$FALLBACK_EXPECTED_BUNDLE_ID" + echo "FALLBACK_EXPECTED_PRODUCT_NAME=$FALLBACK_EXPECTED_PRODUCT_NAME" + echo "FALLBACK_EXPECTED_SHORT_VERSION=$FALLBACK_EXPECTED_SHORT_VERSION" + echo "FALLBACK_EXPECTED_BUNDLE_VERSION=$FALLBACK_EXPECTED_BUNDLE_VERSION" + echo "FALLBACK_EXPECTED_EXECUTABLE=$FALLBACK_EXPECTED_EXECUTABLE" + } >> "$GITHUB_ENV" + SAME_APP_UPGRADE=false if [ "$FALLBACK_APP_NAME" = "$BETA_APP_NAME" ]; then SAME_APP_UPGRADE=true @@ -524,6 +578,7 @@ jobs: - name: Verify signing and Gatekeeper run: | set -euo pipefail + echo "RC_PHASE=verify_signing" >> "$GITHUB_ENV" BETA_APP="/Applications/$BETA_APP_NAME" BETA_DMG=$(find release-assets \( -name "*mac-arm64*.dmg" -o -name "*arm64*.dmg" \) -type f | sort | head -n 1) if [ -z "$BETA_DMG" ]; then @@ -542,12 +597,204 @@ jobs: TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} run: | set -euo pipefail + echo "RC_PHASE=launch_candidate" >> "$GITHUB_ENV" BETA_APP="/Applications/$BETA_APP_NAME" BETA_PLIST="$BETA_APP/Contents/Info.plist" BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$BETA_PLIST") PRODUCT_NAME=$(/usr/libexec/PlistBuddy -c 'Print CFBundleName' "$BETA_PLIST") SHORT_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$BETA_PLIST") BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$BETA_PLIST") + CONNECTOR_PID="" + CONNECTOR_STATE_DIR="$RUNNER_TEMP/evaos-rc-connector-state" + CONNECTOR_START_STDOUT="$RUNNER_TEMP/evaos-rc-connector-start.stdout.txt" + CONNECTOR_START_STDERR="$RUNNER_TEMP/evaos-rc-connector-start.stderr.txt" + PRE_CANARY_STDOUT="$RUNNER_TEMP/evaos-installed-candidate-pre-canary.stdout.json" + PRE_CANARY_STDERR="$RUNNER_TEMP/evaos-installed-candidate-pre-canary.stderr.txt" + CONNECTOR_CANARY_STDOUT="$RUNNER_TEMP/evaos-installed-candidate-connector.stdout.json" + CONNECTOR_CANARY_STDERR="$RUNNER_TEMP/evaos-installed-candidate-connector.stderr.txt" + WORKBENCH_PROCESS_SNAPSHOT="$RUNNER_TEMP/evaos-workbench-processes.txt" + + write_exact_app_process_pids() { + local app_path="$1" + local process_snapshot="$2" + local pid_output="$3" + + if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if node - "$process_snapshot" "$app_path" "$pid_output" <<'NODE' + const fs = require('fs'); + const [snapshotPath, appPath, outputPath] = process.argv.slice(2); + if (!appPath.startsWith('/Applications/') || !appPath.endsWith('.app')) process.exit(2); + const prefix = `${appPath}/Contents/`; + const pids = []; + for (const line of fs.readFileSync(snapshotPath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (match && match[2].startsWith(prefix)) pids.push(Number(match[1])); + } + const unique = [...new Set(pids)].sort((left, right) => left - right); + fs.writeFileSync(outputPath, unique.length > 0 ? `${unique.join('\n')}\n` : '', { mode: 0o600 }); + NODE + then + rm -f "$process_snapshot" + else + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + } + + terminate_exact_app_processes() { + local app_path="$1" + local state_prefix="$2" + local process_snapshot="${state_prefix}.snapshot" + local pid_output="${state_prefix}.pids" + local process_id + + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + while IFS= read -r process_id; do + if [[ "$process_id" =~ ^[0-9]+$ ]]; then + kill "$process_id" >/dev/null 2>&1 || true + fi + done < "$pid_output" + + for _workbench_cleanup_attempt in 1 2 3 4 5; do + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if [ ! -s "$pid_output" ]; then + break + fi + sleep 1 + done + + if [ -s "$pid_output" ]; then + while IFS= read -r process_id; do + if [[ "$process_id" =~ ^[0-9]+$ ]]; then + kill -9 "$process_id" >/dev/null 2>&1 || true + fi + done < "$pid_output" + sleep 1 + fi + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if [ -s "$pid_output" ]; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + rm -f "$process_snapshot" "$pid_output" + } + + connector_job_is_active() { + local job_pid + local job_snapshot + + CONNECTOR_JOB_PROBE_FAILED=false + if ! job_snapshot=$(jobs -p); then + CONNECTOR_JOB_PROBE_FAILED=true + return 1 + fi + for job_pid in $job_snapshot; do + if [ "$job_pid" = "$CONNECTOR_PID" ]; then + return 0 + fi + done + return 1 + } + + cleanup_candidate_processes() { + local exit_status="$1" + local cleanup_failed=0 + trap - EXIT INT TERM + if [ -n "${CONNECTOR_PID:-}" ]; then + echo "RC_CONNECTOR_CLEANUP_ATTEMPTED=true" >> "$GITHUB_ENV" + if connector_job_is_active; then + kill "$CONNECTOR_PID" >/dev/null 2>&1 || true + elif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then + cleanup_failed=1 + fi + if [ "$cleanup_failed" -eq 0 ]; then + for _cleanup_attempt in 1 2 3 4 5; do + if ! connector_job_is_active; then + if [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then + cleanup_failed=1 + fi + break + fi + sleep 1 + done + fi + if [ "$cleanup_failed" -eq 0 ]; then + if connector_job_is_active; then + kill -9 "$CONNECTOR_PID" >/dev/null 2>&1 || true + elif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then + cleanup_failed=1 + fi + fi + if [ "$cleanup_failed" -eq 0 ]; then + for _cleanup_attempt in 1 2 3 4 5; do + if ! connector_job_is_active; then + if [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then + cleanup_failed=1 + fi + break + fi + sleep 1 + done + fi + if [ "$cleanup_failed" -eq 0 ]; then + if connector_job_is_active; then + cleanup_failed=1 + elif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then + cleanup_failed=1 + else + set +e + wait "$CONNECTOR_PID" >/dev/null 2>&1 + set -e + fi + fi + if [ "$cleanup_failed" -eq 0 ]; then + echo "RC_CONNECTOR_CLEANUP_SUCCEEDED=true" >> "$GITHUB_ENV" + else + echo "RC_CONNECTOR_CLEANUP_SUCCEEDED=false" >> "$GITHUB_ENV" + fi + fi + echo "RC_WORKBENCH_CLEANUP_ATTEMPTED=true" >> "$GITHUB_ENV" + if terminate_exact_app_processes "$BETA_APP" "$RUNNER_TEMP/evaos-rc-candidate-cleanup"; then + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=true" >> "$GITHUB_ENV" + else + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=false" >> "$GITHUB_ENV" + cleanup_failed=1 + fi + rm -f "$CONNECTOR_START_STDOUT" "$CONNECTOR_START_STDERR" \ + "$PRE_CANARY_STDOUT" "$PRE_CANARY_STDERR" \ + "$CONNECTOR_CANARY_STDOUT" "$CONNECTOR_CANARY_STDERR" \ + "$WORKBENCH_PROCESS_SNAPSHOT" + if [ -n "${PRE_CANARY_DIR:-}" ]; then + rm -rf "$PRE_CANARY_DIR" + fi + if [ -n "${CONNECTOR_CANARY_DIR:-}" ]; then + rm -rf "$CONNECTOR_CANARY_DIR" + fi + rm -rf "$CONNECTOR_STATE_DIR" + if [ "$cleanup_failed" -ne 0 ]; then + echo "::error::Harness-owned connector or exact candidate Workbench cleanup did not complete safely." + if [ "$exit_status" -eq 0 ]; then + echo "RC_CONNECTOR_READINESS_CLASSIFICATION=cleanup_failed" >> "$GITHUB_ENV" + exit_status=1 + fi + fi + exit "$exit_status" + } + trap 'cleanup_candidate_processes $?' EXIT + trap 'cleanup_candidate_processes 130' INT + trap 'cleanup_candidate_processes 143' TERM if [ "$BETA_APP" != "/Applications/evaOS Workbench.app" ]; then echo "::error::RC canary must install the candidate at /Applications/evaOS Workbench.app, got $BETA_APP" @@ -637,14 +884,34 @@ jobs: open -n "$BETA_APP" sleep 8 - if ! pgrep -f "EvaOSWorkbench|evaOS Workbench" > /dev/null; then - echo "::error::Beta app did not launch a detectable process." + /bin/ps -ww -axo pid=,comm= > "$WORKBENCH_PROCESS_SNAPSHOT" + set +e + node - "$WORKBENCH_PROCESS_SNAPSHOT" <<'NODE' + const fs = require('fs'); + const snapshotPath = process.argv[2]; + const canonicalApp = '/Applications/evaOS Workbench.app'; + try { + const lines = fs.readFileSync(snapshotPath, 'utf8').split(/\r?\n/); + let canonicalMainCount = 0; + for (const line of lines) { + const command = line.replace(/^\s*\d+\s+/, ''); + const match = command.match(/^(.+?\.app)\/Contents\/MacOS\/(?:evaOS Workbench|EvaOSWorkbench)$/); + if (match && match[1] !== canonicalApp) process.exit(2); + if (match) canonicalMainCount += 1; + } + if (canonicalMainCount === 0) process.exit(4); + } catch { + process.exit(3); + } + NODE + WORKBENCH_PROCESS_AUDIT_EXIT=$? + set -e + rm -f "$WORKBENCH_PROCESS_SNAPSHOT" + if [ "$WORKBENCH_PROCESS_AUDIT_EXIT" -eq 4 ]; then + echo "::error::Beta app did not launch its exact canonical main process." exit 1 - fi - STALE_RUNNING_APPS="$(ps -axo pid=,command= | grep -E '/Volumes/LEXAR/Codex/.+\\.app/Contents/MacOS/(evaOS Workbench|EvaOSWorkbench)' || true)" - if [ -n "$STALE_RUNNING_APPS" ]; then - echo "::error::Stale Workbench app process is running from a Lexar proof/extract path." - printf '%s\n' "$STALE_RUNNING_APPS" + elif [ "$WORKBENCH_PROCESS_AUDIT_EXIT" -ne 0 ]; then + echo "::error::A noncanonical Workbench app process is running during installed-candidate proof." exit 1 fi @@ -653,9 +920,10 @@ jobs: echo "::error::Installed Workbench bridge launcher is missing or not executable." exit 1 fi - PRE_CANARY_DIR="$PROOF_DIR/installed-candidate-pre-canary" - CONNECTOR_CANARY_DIR="$PROOF_DIR/installed-candidate-connector" + PRE_CANARY_DIR="$RUNNER_TEMP/evaos-installed-candidate-pre-canary" + CONNECTOR_CANARY_DIR="$RUNNER_TEMP/evaos-installed-candidate-connector" rm -rf "$PRE_CANARY_DIR" "$CONNECTOR_CANARY_DIR" + echo "RC_PHASE=pre_canary" >> "$GITHUB_ENV" set +e "$BRIDGE_COMMAND" pre-canary \ --json \ @@ -665,8 +933,8 @@ jobs: --expected-source-commit "$TAG_COMMIT" \ --canary-artifact-root "$RUNNER_TEMP" \ --artifact-dir "$PRE_CANARY_DIR" \ - > "$PROOF_DIR/installed-candidate-pre-canary.stdout.json" \ - 2> "$PROOF_DIR/installed-candidate-pre-canary.stderr.txt" + > "$PRE_CANARY_STDOUT" \ + 2> "$PRE_CANARY_STDERR" PRE_CANARY_EXIT=$? set -e PRE_CANARY_REPORT="$PRE_CANARY_DIR/qa-report.json" @@ -721,28 +989,266 @@ jobs: fi cp "$PRE_CANARY_REPORT" "$PROOF_DIR/installed-candidate-pre-canary.json" - TOKEN_FILE="$HOME/Library/Application Support/evaos-desktop-bridge/connector.token" - for _attempt in $(seq 1 30); do - if [ -s "$TOKEN_FILE" ] && curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8765/health >/dev/null; then + write_connector_start_summary() { + node - "$PROOF_DIR/installed-candidate-connector-start.json" "$@" <<'NODE' + const fs = require('fs'); + const [ + outputPath, + okRaw, + classification, + startInvokedRaw, + processRunningRaw, + processExitCodeRaw, + attemptsRaw, + tokenExistsRaw, + tokenRegularRaw, + tokenOwnerMatchRaw, + tokenMode600Raw, + tokenNonemptyRaw, + healthReachableRaw, + ] = process.argv.slice(2); + const classifications = new Set([ + 'ready', + 'preexisting_state', + 'preexisting_listener', + 'process_exited', + 'token_missing', + 'token_invalid', + 'health_unreachable', + ]); + const parseBoolean = (value) => { + if (value !== 'true' && value !== 'false') throw new Error('invalid boolean'); + return value === 'true'; + }; + if (!classifications.has(classification)) throw new Error('invalid readiness classification'); + const attempts = Number(attemptsRaw); + if (!Number.isInteger(attempts) || attempts < 0 || attempts > 100) throw new Error('invalid attempt count'); + const processExitCode = processExitCodeRaw === '' ? null : Number(processExitCodeRaw); + if ( + processExitCode !== null && + (!Number.isInteger(processExitCode) || processExitCode < 0 || processExitCode > 255) + ) { + throw new Error('invalid process exit code'); + } + const payload = { + schema: 'evaos-installed-connector-harness-start/v1', + ok: parseBoolean(okRaw), + classification, + mode: 'harness-owned-loopback', + startInvoked: parseBoolean(startInvokedRaw), + processRunning: parseBoolean(processRunningRaw), + processExitCode, + attempts, + token: { + atomicRead: classification === 'ready', + exists: parseBoolean(tokenExistsRaw), + regularFile: parseBoolean(tokenRegularRaw), + ownerMatchesRunner: parseBoolean(tokenOwnerMatchRaw), + mode0600: parseBoolean(tokenMode600Raw), + nonempty: parseBoolean(tokenNonemptyRaw), + }, + health: { reachable: parseBoolean(healthReachableRaw) }, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + NODE + } + + read_connector_token_state() { + TOKEN_EXISTS=false + TOKEN_REGULAR=false + TOKEN_OWNER_MATCH=false + TOKEN_MODE_600=false + TOKEN_NONEMPTY=false + if [ -e "$TOKEN_FILE" ] || [ -L "$TOKEN_FILE" ]; then + TOKEN_EXISTS=true + fi + if [ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ]; then + TOKEN_REGULAR=true + if [ "$(stat -f '%u' "$TOKEN_FILE" 2>/dev/null || true)" = "$(id -u)" ]; then + TOKEN_OWNER_MATCH=true + fi + if [ "$(stat -f '%Lp' "$TOKEN_FILE" 2>/dev/null || true)" = "600" ]; then + TOKEN_MODE_600=true + fi + if [ -s "$TOKEN_FILE" ]; then + TOKEN_NONEMPTY=true + fi + fi + } + + read_connector_token_atomically() { + node - "$TOKEN_FILE" <<'NODE' + const fs = require('fs'); + const tokenPath = process.argv[2]; + const noFollow = fs.constants.O_NOFOLLOW; + let descriptor; + try { + if (!Number.isInteger(noFollow) || noFollow <= 0 || typeof process.getuid !== 'function') process.exit(2); + descriptor = fs.openSync(tokenPath, fs.constants.O_RDONLY | noFollow); + const before = fs.fstatSync(descriptor); + if ( + !before.isFile() || + before.uid !== process.getuid() || + (before.mode & 0o777) !== 0o600 || + before.size < 32 || + before.size > 129 + ) { + process.exit(2); + } + const buffer = Buffer.alloc(130); + const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0); + if (bytesRead === buffer.length || bytesRead !== before.size) process.exit(2); + const raw = buffer.subarray(0, bytesRead).toString('utf8'); + const after = fs.fstatSync(descriptor); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + !/^[A-Za-z0-9_-]{32,128}\n?$/.test(raw) + ) { + process.exit(2); + } + process.stdout.write(raw.trimEnd()); + } catch { + process.exitCode = 2; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + NODE + } + + publish_connector_state() { + { + echo "RC_CONNECTOR_PROCESS_RUNNING=$CONNECTOR_PROCESS_RUNNING" + echo "RC_CONNECTOR_PROCESS_EXIT_CODE=$CONNECTOR_PROCESS_EXIT_CODE" + echo "RC_CONNECTOR_ATTEMPTS=$CONNECTOR_ATTEMPTS" + echo "RC_CONNECTOR_TOKEN_EXISTS=$TOKEN_EXISTS" + echo "RC_CONNECTOR_TOKEN_REGULAR=$TOKEN_REGULAR" + echo "RC_CONNECTOR_TOKEN_OWNER_MATCH=$TOKEN_OWNER_MATCH" + echo "RC_CONNECTOR_TOKEN_MODE_600=$TOKEN_MODE_600" + echo "RC_CONNECTOR_TOKEN_NONEMPTY=$TOKEN_NONEMPTY" + echo "RC_CONNECTOR_HEALTH_REACHABLE=$CONNECTOR_HEALTH_REACHABLE" + echo "RC_CONNECTOR_READINESS_CLASSIFICATION=$CONNECTOR_READINESS_CLASSIFICATION" + } >> "$GITHUB_ENV" + } + + echo "RC_PHASE=connector_start" >> "$GITHUB_ENV" + rm -rf "$CONNECTOR_STATE_DIR" + mkdir -p "$CONNECTOR_STATE_DIR" + chmod 700 "$CONNECTOR_STATE_DIR" + TOKEN_FILE="$CONNECTOR_STATE_DIR/connector.token" + CONNECTOR_ATTEMPTS=0 + CONNECTOR_PROCESS_RUNNING=false + CONNECTOR_PROCESS_EXIT_CODE="" + CONNECTOR_HEALTH_REACHABLE=false + CONNECTOR_READINESS_CLASSIFICATION=not_started + read_connector_token_state + if [ "$TOKEN_EXISTS" = true ]; then + CONNECTOR_READINESS_CLASSIFICATION=preexisting_state + publish_connector_state + write_connector_start_summary false preexisting_state false false "" 0 \ + "$TOKEN_EXISTS" "$TOKEN_REGULAR" "$TOKEN_OWNER_MATCH" "$TOKEN_MODE_600" "$TOKEN_NONEMPTY" false + echo "::error::The isolated connector harness did not begin from an empty token state." + exit 1 + fi + if /usr/sbin/lsof -nP -iTCP:8765 -sTCP:LISTEN >/dev/null 2>&1; then + CONNECTOR_READINESS_CLASSIFICATION=preexisting_listener + publish_connector_state + write_connector_start_summary false preexisting_listener false false "" 0 \ + false false false false false false + echo "::error::The connector canary loopback endpoint was already occupied before harness start." + exit 1 + fi + + EVAOS_DESKTOP_BRIDGE_STATE_DIR="$CONNECTOR_STATE_DIR" \ + EVAOS_DESKTOP_BRIDGE_MODE=customer-mac-connector \ + EVAOS_DESKTOP_BRIDGE_MANAGED_BY=workbench-session \ + EVAOS_DESKTOP_BRIDGE_RESPONSIBLE_BUNDLE_ID=com.evaos.workbench \ + EVAOS_DESKTOP_BRIDGE_RESPONSIBLE_APP_PATH="$BETA_APP" \ + "$BRIDGE_COMMAND" serve \ + --host 127.0.0.1 \ + --port 8765 \ + --token-file "$TOKEN_FILE" \ + > "$CONNECTOR_START_STDOUT" \ + 2> "$CONNECTOR_START_STDERR" & + CONNECTOR_PID=$! + echo "RC_CONNECTOR_START_INVOKED=true" >> "$GITHUB_ENV" + + CONNECTOR_DEADLINE=$((SECONDS + 45)) + while [ "$SECONDS" -lt "$CONNECTOR_DEADLINE" ]; do + CONNECTOR_ATTEMPTS=$((CONNECTOR_ATTEMPTS + 1)) + read_connector_token_state + if ! kill -0 "$CONNECTOR_PID" >/dev/null 2>&1; then + set +e + wait "$CONNECTOR_PID" >/dev/null 2>&1 + CONNECTOR_PROCESS_EXIT_CODE=$? + set -e + CONNECTOR_PID="" + CONNECTOR_PROCESS_RUNNING=false + CONNECTOR_READINESS_CLASSIFICATION=process_exited + { + echo "RC_CONNECTOR_CLEANUP_ATTEMPTED=true" + echo "RC_CONNECTOR_CLEANUP_SUCCEEDED=true" + } >> "$GITHUB_ENV" + publish_connector_state + write_connector_start_summary false process_exited true false "$CONNECTOR_PROCESS_EXIT_CODE" \ + "$CONNECTOR_ATTEMPTS" "$TOKEN_EXISTS" "$TOKEN_REGULAR" "$TOKEN_OWNER_MATCH" \ + "$TOKEN_MODE_600" "$TOKEN_NONEMPTY" false + echo "::error::The harness-owned installed connector exited before becoming ready." + exit 1 + fi + CONNECTOR_PROCESS_RUNNING=true + if [ "$TOKEN_REGULAR" = true ] && [ "$TOKEN_OWNER_MATCH" = true ] && \ + [ "$TOKEN_MODE_600" = true ] && [ "$TOKEN_NONEMPTY" = true ] && \ + curl --fail --silent --max-time 2 http://127.0.0.1:8765/health >/dev/null 2>&1; then + CONNECTOR_HEALTH_REACHABLE=true break fi sleep 1 done - if [ ! -s "$TOKEN_FILE" ]; then - echo "::error::Installed Workbench did not create its connector token." - exit 1 + + read_connector_token_state + CONNECTOR_TOKEN="" + ATOMIC_TOKEN_EXIT=1 + if [ "$TOKEN_REGULAR" = true ] && [ "$TOKEN_OWNER_MATCH" = true ] && \ + [ "$TOKEN_MODE_600" = true ] && [ "$TOKEN_NONEMPTY" = true ]; then + set +e + CONNECTOR_TOKEN=$(read_connector_token_atomically 2>/dev/null) + ATOMIC_TOKEN_EXIT=$? + set -e fi - if ! curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8765/health >/dev/null; then - echo "::error::Installed Workbench connector did not become reachable." - exit 1 + if [ "$TOKEN_EXISTS" != true ]; then + CONNECTOR_READINESS_CLASSIFICATION=token_missing + elif [ "$TOKEN_REGULAR" != true ] || [ "$TOKEN_OWNER_MATCH" != true ] || \ + [ "$TOKEN_MODE_600" != true ] || [ "$TOKEN_NONEMPTY" != true ] || \ + [ "$ATOMIC_TOKEN_EXIT" -ne 0 ] || [ -z "$CONNECTOR_TOKEN" ]; then + CONNECTOR_READINESS_CLASSIFICATION=token_invalid + elif [ "$CONNECTOR_HEALTH_REACHABLE" != true ]; then + CONNECTOR_READINESS_CLASSIFICATION=health_unreachable + else + CONNECTOR_READINESS_CLASSIFICATION=ready fi - CONNECTOR_TOKEN=$(tr -d '\r\n' < "$TOKEN_FILE") - if [ -z "$CONNECTOR_TOKEN" ]; then - echo "::error::Installed Workbench connector token is empty." + publish_connector_state + write_connector_start_summary \ + "$([ "$CONNECTOR_READINESS_CLASSIFICATION" = ready ] && printf true || printf false)" \ + "$CONNECTOR_READINESS_CLASSIFICATION" true "$CONNECTOR_PROCESS_RUNNING" "" \ + "$CONNECTOR_ATTEMPTS" "$TOKEN_EXISTS" "$TOKEN_REGULAR" "$TOKEN_OWNER_MATCH" \ + "$TOKEN_MODE_600" "$TOKEN_NONEMPTY" "$CONNECTOR_HEALTH_REACHABLE" + if [ "$CONNECTOR_READINESS_CLASSIFICATION" != ready ]; then + CONNECTOR_TOKEN="" + unset CONNECTOR_TOKEN + echo "::error::The harness-owned installed connector did not become safely ready." exit 1 fi + { + echo "RC_PHASE=connector_qa" + echo "RC_QA_STARTED=true" + } >> "$GITHUB_ENV" set +e - EVAOS_DESKTOP_BRIDGE_TOKEN="$CONNECTOR_TOKEN" "$BRIDGE_COMMAND" qa-canary \ + EVAOS_DESKTOP_BRIDGE_STATE_DIR="$CONNECTOR_STATE_DIR" \ + EVAOS_DESKTOP_BRIDGE_TOKEN="$CONNECTOR_TOKEN" \ + "$BRIDGE_COMMAND" qa-canary \ --connector-url http://127.0.0.1:8765 \ --artifact-dir "$CONNECTOR_CANARY_DIR" \ --version-under-test "$SHORT_VERSION" \ @@ -751,8 +1257,8 @@ jobs: --surface connector \ --suite control_start \ --operator-ack-live-control \ - > "$PROOF_DIR/installed-candidate-connector.stdout.json" \ - 2> "$PROOF_DIR/installed-candidate-connector.stderr.txt" + > "$CONNECTOR_CANARY_STDOUT" \ + 2> "$CONNECTOR_CANARY_STDERR" QA_CANARY_EXIT=$? set -e if [ -f "$CONNECTOR_CANARY_DIR/qa-report.json" ]; then @@ -772,10 +1278,14 @@ jobs: fi unset CONNECTOR_TOKEN if [ "$QA_CANARY_EXIT" -ne 0 ]; then + { + echo "RC_QA_SUCCEEDED=false" + echo "RC_CONNECTOR_READINESS_CLASSIFICATION=qa_failed" + } >> "$GITHUB_ENV" echo "::error::Installed candidate connector canary failed; inspect the sanitized proof artifact." exit 1 fi - pkill -f "EvaOSWorkbench|evaOS Workbench" || true + echo "RC_QA_SUCCEEDED=true" >> "$GITHUB_ENV" { echo "PASS: evaOS Workbench launched with stable Workbench identity and no upstream AionUi feed." @@ -808,13 +1318,25 @@ jobs: } > "$PROOF_DIR/updater-feed-audit.md" - name: Roll back beta and verify fallback + id: rollback_candidate + if: ${{ always() && steps.install_apps.outputs.mutation_started == 'true' }} env: FALLBACK_APP_NAME: ${{ github.event.inputs.fallback_app_name }} BROKER_SESSION_PROOF_REF: ${{ github.event.inputs.broker_session_proof_ref }} + PRIOR_JOB_STATUS: ${{ job.status }} + INSTALL_STEP_OUTCOME: ${{ steps.install_apps.outcome }} run: | set -euo pipefail + echo "RC_ROLLBACK_ATTEMPTED=true" >> "$GITHUB_ENV" + if [ "$PRIOR_JOB_STATUS" = "success" ]; then + echo "RC_PHASE=rollback" >> "$GITHUB_ENV" + fi FALLBACK_APP="/Applications/$FALLBACK_APP_NAME" BETA_APP="/Applications/$BETA_APP_NAME" + ROLLBACK_SAME_APP=false + if [ "$FALLBACK_APP_NAME" = "$BETA_APP_NAME" ]; then + ROLLBACK_SAME_APP=true + fi install_app_from_dmg() { local dmg="$1" @@ -854,8 +1376,139 @@ jobs: ditto "$app_path" "/Applications/$app_name" } + write_exact_app_process_pids() { + local app_path="$1" + local process_snapshot="$2" + local pid_output="$3" + + if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if node - "$process_snapshot" "$app_path" "$pid_output" <<'NODE' + const fs = require('fs'); + const [snapshotPath, appPath, outputPath] = process.argv.slice(2); + if (!appPath.startsWith('/Applications/') || !appPath.endsWith('.app')) process.exit(2); + const prefix = `${appPath}/Contents/`; + const pids = []; + for (const line of fs.readFileSync(snapshotPath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (match && match[2].startsWith(prefix)) pids.push(Number(match[1])); + } + const unique = [...new Set(pids)].sort((left, right) => left - right); + fs.writeFileSync(outputPath, unique.length > 0 ? `${unique.join('\n')}\n` : '', { mode: 0o600 }); + NODE + then + rm -f "$process_snapshot" + else + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + } + + write_exact_app_main_pids() { + local app_path="$1" + local executable_name="$2" + local process_snapshot="$3" + local pid_output="$4" + + if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if node - "$process_snapshot" "$app_path" "$executable_name" "$pid_output" <<'NODE' + const fs = require('fs'); + const [snapshotPath, appPath, executableName, outputPath] = process.argv.slice(2); + if ( + !appPath.startsWith('/Applications/') || + !appPath.endsWith('.app') || + !/^[A-Za-z0-9._ -]+$/.test(executableName) + ) { + process.exit(2); + } + const executablePath = `${appPath}/Contents/MacOS/${executableName}`; + const pids = []; + for (const line of fs.readFileSync(snapshotPath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (match && match[2] === executablePath) { + pids.push(Number(match[1])); + } + } + const unique = [...new Set(pids)].sort((left, right) => left - right); + fs.writeFileSync(outputPath, unique.length > 0 ? `${unique.join('\n')}\n` : '', { mode: 0o600 }); + NODE + then + rm -f "$process_snapshot" + else + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + } + + terminate_exact_app_processes() { + local app_path="$1" + local state_prefix="$2" + local process_snapshot="${state_prefix}.snapshot" + local pid_output="${state_prefix}.pids" + local process_id + + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + while IFS= read -r process_id; do + if [[ "$process_id" =~ ^[0-9]+$ ]]; then + kill "$process_id" >/dev/null 2>&1 || true + fi + done < "$pid_output" + + for _workbench_cleanup_attempt in 1 2 3 4 5; do + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if [ ! -s "$pid_output" ]; then + break + fi + sleep 1 + done + + if [ -s "$pid_output" ]; then + while IFS= read -r process_id; do + if [[ "$process_id" =~ ^[0-9]+$ ]]; then + kill -9 "$process_id" >/dev/null 2>&1 || true + fi + done < "$pid_output" + sleep 1 + fi + if ! write_exact_app_process_pids "$app_path" "$process_snapshot" "$pid_output"; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + if [ -s "$pid_output" ]; then + rm -f "$process_snapshot" "$pid_output" + return 1 + fi + rm -f "$process_snapshot" "$pid_output" + } + + echo "RC_WORKBENCH_CLEANUP_ATTEMPTED=true" >> "$GITHUB_ENV" + if ! terminate_exact_app_processes "$BETA_APP" "$RUNNER_TEMP/evaos-rollback-candidate-cleanup"; then + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=false" >> "$GITHUB_ENV" + echo "::error::Exact candidate Workbench processes survived bounded rollback cleanup." + exit 1 + fi + if [ "$FALLBACK_APP" != "$BETA_APP" ] && \ + ! terminate_exact_app_processes "$FALLBACK_APP" "$RUNNER_TEMP/evaos-rollback-fallback-prelaunch-cleanup"; then + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=false" >> "$GITHUB_ENV" + echo "::error::Exact fallback Workbench processes survived bounded prelaunch cleanup." + exit 1 + fi + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=true" >> "$GITHUB_ENV" + rm -rf "$BETA_APP" - if [ "${SAME_APP_UPGRADE:-false}" = "true" ] || [ "${FALLBACK_REINSTALL_REQUIRED:-false}" = "true" ]; then + if [ "$INSTALL_STEP_OUTCOME" != "success" ] || [ "$ROLLBACK_SAME_APP" = "true" ] || \ + [ "${FALLBACK_REINSTALL_REQUIRED:-false}" = "true" ] || [ ! -d "$FALLBACK_APP" ]; then case "${FALLBACK_ASSET:-}" in *.dmg) install_app_from_dmg "$FALLBACK_ASSET" "$FALLBACK_APP_NAME" "$RUNNER_TEMP/rollback-fallback-dmg" @@ -868,11 +1521,11 @@ jobs: exit 1 ;; esac - else - if [ -d "$BETA_APP" ]; then - echo "::error::Beta app is still present after rollback removal." - exit 1 - fi + fi + + if [ "$ROLLBACK_SAME_APP" != "true" ] && [ -d "$BETA_APP" ]; then + echo "::error::Beta app is still present after rollback removal." + exit 1 fi if [ ! -d "$FALLBACK_APP" ]; then @@ -880,13 +1533,103 @@ jobs: exit 1 fi + FALLBACK_PLIST="$FALLBACK_APP/Contents/Info.plist" + if [ ! -f "$FALLBACK_PLIST" ]; then + echo "::error::Restored fallback app is missing Info.plist." + exit 1 + fi + ACTUAL_FALLBACK_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$FALLBACK_PLIST") + ACTUAL_FALLBACK_PRODUCT_NAME=$(/usr/libexec/PlistBuddy -c 'Print CFBundleName' "$FALLBACK_PLIST") + ACTUAL_FALLBACK_SHORT_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$FALLBACK_PLIST") + ACTUAL_FALLBACK_BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$FALLBACK_PLIST") + ACTUAL_FALLBACK_EXECUTABLE=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$FALLBACK_PLIST") + if [[ ! "$ACTUAL_FALLBACK_BUNDLE_ID" =~ ^[A-Za-z0-9.-]+$ ]] || \ + [[ ! "$ACTUAL_FALLBACK_PRODUCT_NAME" =~ ^[A-Za-z0-9._\ -]+$ ]] || \ + [[ ! "$ACTUAL_FALLBACK_SHORT_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || \ + [[ ! "$ACTUAL_FALLBACK_BUNDLE_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || \ + [[ ! "$ACTUAL_FALLBACK_EXECUTABLE" =~ ^[A-Za-z0-9._\ -]+$ ]]; then + echo "::error::Restored fallback app has unsafe or incomplete bundle identity metadata." + exit 1 + fi + if [ -n "${FALLBACK_EXPECTED_BUNDLE_ID:-}" ] || \ + [ -n "${FALLBACK_EXPECTED_PRODUCT_NAME:-}" ] || \ + [ -n "${FALLBACK_EXPECTED_SHORT_VERSION:-}" ] || \ + [ -n "${FALLBACK_EXPECTED_BUNDLE_VERSION:-}" ] || \ + [ -n "${FALLBACK_EXPECTED_EXECUTABLE:-}" ]; then + if [ "$ACTUAL_FALLBACK_BUNDLE_ID" != "${FALLBACK_EXPECTED_BUNDLE_ID:-}" ] || \ + [ "$ACTUAL_FALLBACK_PRODUCT_NAME" != "${FALLBACK_EXPECTED_PRODUCT_NAME:-}" ] || \ + [ "$ACTUAL_FALLBACK_SHORT_VERSION" != "${FALLBACK_EXPECTED_SHORT_VERSION:-}" ] || \ + [ "$ACTUAL_FALLBACK_BUNDLE_VERSION" != "${FALLBACK_EXPECTED_BUNDLE_VERSION:-}" ] || \ + [ "$ACTUAL_FALLBACK_EXECUTABLE" != "${FALLBACK_EXPECTED_EXECUTABLE:-}" ]; then + echo "::error::Restored fallback bundle identity does not match the downloaded fallback release asset." + exit 1 + fi + fi + + FALLBACK_MAIN_SNAPSHOT="$RUNNER_TEMP/evaos-rollback-fallback-main.snapshot" + FALLBACK_MAIN_PIDS="$RUNNER_TEMP/evaos-rollback-fallback-main.pids" + write_exact_app_main_pids \ + "$FALLBACK_APP" \ + "$ACTUAL_FALLBACK_EXECUTABLE" \ + "$FALLBACK_MAIN_SNAPSHOT" \ + "$FALLBACK_MAIN_PIDS" + if [ -s "$FALLBACK_MAIN_PIDS" ]; then + echo "::error::Fallback launch baseline was not clean after exact process cleanup." + rm -f "$FALLBACK_MAIN_SNAPSHOT" "$FALLBACK_MAIN_PIDS" + exit 1 + fi + open -n "$FALLBACK_APP" - sleep 8 - if ! pgrep -f "${FALLBACK_APP_NAME%.app}" > /dev/null; then + FALLBACK_LAUNCH_DEADLINE=$((SECONDS + 15)) + while [ "$SECONDS" -lt "$FALLBACK_LAUNCH_DEADLINE" ]; do + write_exact_app_main_pids \ + "$FALLBACK_APP" \ + "$ACTUAL_FALLBACK_EXECUTABLE" \ + "$FALLBACK_MAIN_SNAPSHOT" \ + "$FALLBACK_MAIN_PIDS" + if [ -s "$FALLBACK_MAIN_PIDS" ]; then + break + fi + sleep 1 + done + if [ ! -s "$FALLBACK_MAIN_PIDS" ]; then echo "::error::Released fallback app did not launch after beta removal." + rm -f "$FALLBACK_MAIN_SNAPSHOT" "$FALLBACK_MAIN_PIDS" + exit 1 + fi + FALLBACK_LAUNCH_PID="" + while IFS= read -r process_id; do + if [[ "$process_id" =~ ^[0-9]+$ ]]; then + FALLBACK_LAUNCH_PID="$process_id" + break + fi + done < "$FALLBACK_MAIN_PIDS" + if [ -z "$FALLBACK_LAUNCH_PID" ]; then + echo "::error::Released fallback app launch did not yield a valid exact-path process." + rm -f "$FALLBACK_MAIN_SNAPSHOT" "$FALLBACK_MAIN_PIDS" + exit 1 + fi + FALLBACK_LAUNCH_DWELL_SECONDS=8 + for _fallback_dwell_second in 1 2 3 4 5 6 7 8; do + sleep 1 + write_exact_app_main_pids \ + "$FALLBACK_APP" \ + "$ACTUAL_FALLBACK_EXECUTABLE" \ + "$FALLBACK_MAIN_SNAPSHOT" \ + "$FALLBACK_MAIN_PIDS" + if ! /usr/bin/grep -Fx "$FALLBACK_LAUNCH_PID" "$FALLBACK_MAIN_PIDS" >/dev/null 2>&1; then + echo "::error::Released fallback app did not remain running through the launch dwell." + rm -f "$FALLBACK_MAIN_SNAPSHOT" "$FALLBACK_MAIN_PIDS" + exit 1 + fi + done + echo "RC_FALLBACK_LAUNCH_VERIFIED=true" >> "$GITHUB_ENV" + rm -f "$FALLBACK_MAIN_SNAPSHOT" "$FALLBACK_MAIN_PIDS" + if ! terminate_exact_app_processes "$FALLBACK_APP" "$RUNNER_TEMP/evaos-rollback-fallback-postlaunch-cleanup"; then + echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=false" >> "$GITHUB_ENV" + echo "::error::Exact fallback Workbench processes survived bounded postlaunch cleanup." exit 1 fi - pkill -f "${FALLBACK_APP_NAME%.app}" || true /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -dump \ | grep -Ei "evaos|workbench|${FALLBACK_APP_NAME%.app}" \ @@ -895,12 +1638,16 @@ jobs: { echo "PASS: candidate app rolled back; released fallback app launched; data/cache disposition recorded; protocol handler state inspected; broker login/session proof reference: $BROKER_SESSION_PROOF_REF." echo "Candidate app rolled back: /Applications/$BETA_APP_NAME" - echo "Same-app upgrade: ${SAME_APP_UPGRADE:-false}" + echo "Same-app upgrade: $ROLLBACK_SAME_APP" echo "Fallback app launched: $FALLBACK_APP" + echo "Fallback exact bundle identity verified: true" + echo "Fallback exact main-process path verified: true" + echo "Fallback exact main-process dwell seconds: $FALLBACK_LAUNCH_DWELL_SECONDS" echo "data/cache disposition: GitHub runner is ephemeral; no user data migrated or deleted beyond beta app bundle removal." echo "protocol handler state: evaos-workbench / com.evaos.workbench; see launchservices-protocol-state.txt" echo "broker login/session: $BROKER_SESSION_PROOF_REF" } > "$PROOF_DIR/rollback-smoke.md" + echo "RC_ROLLBACK_SUCCEEDED=true" >> "$GITHUB_ENV" - name: Finalize and verify RC proof env: @@ -911,6 +1658,7 @@ jobs: EVAOS_BETA_RC_RELEASE_ASSETS_DIR: release-assets run: | set -euo pipefail + echo "RC_PHASE=finalize_proof" >> "$GITHUB_ENV" { echo "Support route: 100yenadmin/evaOS-GUI." echo "The released macOS app remains the fallback while beta is gated." @@ -931,6 +1679,7 @@ jobs: NODE node scripts/evaosBetaReleaseGate.js verify-rc-proof "$PROOF_DIR" "$TAG" + echo "RC_PHASE=upload_proof" >> "$GITHUB_ENV" - name: Upload RC proof packet uses: actions/upload-artifact@v4 @@ -940,16 +1689,123 @@ jobs: if-no-files-found: error retention-days: 90 + - name: Write sanitized RC failure packet + if: ${{ failure() && steps.prepare_proof.outcome == 'success' }} + env: + INSTALL_MUTATION_STARTED: ${{ steps.install_apps.outputs.mutation_started }} + ROLLBACK_STEP_OUTCOME: ${{ steps.rollback_candidate.outcome }} + run: | + set -euo pipefail + rm -rf rc-failure-proof + mkdir -p rc-failure-proof + node - rc-failure-proof/failure-summary.json <<'NODE' + const fs = require('fs'); + const outputPath = process.argv[2]; + const allowedPhases = new Set([ + 'prepare_proof', + 'verify_updater_zip', + 'install_candidate', + 'verify_signing', + 'launch_candidate', + 'pre_canary', + 'connector_start', + 'connector_qa', + 'rollback', + 'finalize_proof', + 'upload_proof', + ]); + const allowedReadiness = new Set([ + 'not_started', + 'ready', + 'preexisting_state', + 'preexisting_listener', + 'process_exited', + 'token_missing', + 'token_invalid', + 'health_unreachable', + 'qa_failed', + 'cleanup_failed', + ]); + const strictBoolean = (name) => { + const value = process.env[name]; + if (value !== 'true' && value !== 'false') throw new Error(`invalid ${name}`); + return value === 'true'; + }; + const attempts = Number(process.env.RC_CONNECTOR_ATTEMPTS || '0'); + if (!Number.isInteger(attempts) || attempts < 0 || attempts > 100) { + throw new Error('invalid connector attempt count'); + } + const rawExitCode = process.env.RC_CONNECTOR_PROCESS_EXIT_CODE || ''; + const processExitCode = rawExitCode === '' ? null : Number(rawExitCode); + if ( + processExitCode !== null && + (!Number.isInteger(processExitCode) || processExitCode < 0 || processExitCode > 255) + ) { + throw new Error('invalid connector process exit code'); + } + const phase = allowedPhases.has(process.env.RC_PHASE) ? process.env.RC_PHASE : 'unknown'; + const readiness = allowedReadiness.has(process.env.RC_CONNECTOR_READINESS_CLASSIFICATION) + ? process.env.RC_CONNECTOR_READINESS_CLASSIFICATION + : 'unknown'; + const payload = { + schema: 'evaos-beta-rc-sanitized-failure/v1', + result: 'failed', + phase, + applicationMutationStarted: process.env.INSTALL_MUTATION_STARTED === 'true', + connector: { + startInvoked: strictBoolean('RC_CONNECTOR_START_INVOKED'), + processRunningAtReadiness: strictBoolean('RC_CONNECTOR_PROCESS_RUNNING'), + processExitCode, + attempts, + readiness, + token: { + exists: strictBoolean('RC_CONNECTOR_TOKEN_EXISTS'), + regularFile: strictBoolean('RC_CONNECTOR_TOKEN_REGULAR'), + ownerMatchesRunner: strictBoolean('RC_CONNECTOR_TOKEN_OWNER_MATCH'), + mode0600: strictBoolean('RC_CONNECTOR_TOKEN_MODE_600'), + nonempty: strictBoolean('RC_CONNECTOR_TOKEN_NONEMPTY'), + }, + healthReachable: strictBoolean('RC_CONNECTOR_HEALTH_REACHABLE'), + qaStarted: strictBoolean('RC_QA_STARTED'), + qaSucceeded: strictBoolean('RC_QA_SUCCEEDED'), + cleanupAttempted: strictBoolean('RC_CONNECTOR_CLEANUP_ATTEMPTED'), + cleanupSucceeded: strictBoolean('RC_CONNECTOR_CLEANUP_SUCCEEDED'), + }, + rollback: { + attempted: strictBoolean('RC_ROLLBACK_ATTEMPTED'), + workbenchCleanupAttempted: strictBoolean('RC_WORKBENCH_CLEANUP_ATTEMPTED'), + workbenchCleanupSucceeded: strictBoolean('RC_WORKBENCH_CLEANUP_SUCCEEDED'), + fallbackLaunchVerified: strictBoolean('RC_FALLBACK_LAUNCH_VERIFIED'), + succeeded: + strictBoolean('RC_ROLLBACK_SUCCEEDED') && process.env.ROLLBACK_STEP_OUTCOME === 'success', + }, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + NODE + + - name: Upload sanitized RC failure packet + if: ${{ failure() && steps.prepare_proof.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: evaos-beta-rc-failure-${{ github.event.inputs.tag }} + path: rc-failure-proof + if-no-files-found: error + retention-days: 30 + - name: Summary + if: ${{ always() }} env: TAG: ${{ github.event.inputs.tag }} FALLBACK_REPO: ${{ github.event.inputs.fallback_release_repo }} FALLBACK_TAG: ${{ github.event.inputs.fallback_release_tag }} + JOB_STATUS: ${{ job.status }} run: | { echo "### evaOS Beta RC Canary" echo "" echo "- Tag: \`${TAG}\`" echo "- Fallback release: \`${FALLBACK_REPO}@${FALLBACK_TAG}\`" + echo "- Result: \`${JOB_STATUS}\`" echo "- Proof artifact: \`evaos-beta-rc-proof-${TAG}\`" - } >> $GITHUB_STEP_SUMMARY + echo "- Sanitized failure artifact (failures only): \`evaos-beta-rc-failure-${TAG}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py index dbd9442084..14e589e496 100644 --- a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py @@ -668,6 +668,7 @@ def _run_local_workbench_cli( "LANG", "LC_ALL", "__CF_USER_TEXT_ENCODING", + "EVAOS_DESKTOP_BRIDGE_STATE_DIR", ) if (value := os.environ.get(key)) }, diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 7ab83af8ed..b0efb40b11 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -18,6 +18,7 @@ const committedBridgeSourceIdentityCache = new Map(); const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on', 'evaos-beta']); const LIVE_CANARY_VERIFIER_SHA256 = '701828332e3c35497294359980944e7021064384a6a0304157af7885897462bd'; const FUNCTIONAL_SMOKE_SHAPE_RUN_SHA256 = '7d3bc23e52e3e342782b2903664572b15754782db4e67155cdb869c4c8d93d3b'; +const RC_FAILURE_WRITER_RUN_SHA256 = '3bc736c433a70d49fbcceb08c2b9ce714cf2fabb350719eb481ba08a9b81c31f'; const REQUIRED_PUBLIC_BETA_CODE_SIGNING_ENV = [ { @@ -205,6 +206,20 @@ const REQUIRED_RC_PROOF_CHECKS = [ 'com.evaos.workbench', ], }, + { + id: 'installed-candidate-connector-start', + evidence: 'installed-candidate-connector-start.json', + requiredText: [ + '"ok": true', + '"classification": "ready"', + '"mode": "harness-owned-loopback"', + '"startInvoked": true', + '"processRunning": true', + '"atomicRead": true', + '"mode0600": true', + '"reachable": true', + ], + }, { id: 'installed-candidate-connector', evidence: 'installed-candidate-connector.json', @@ -260,6 +275,9 @@ const REQUIRED_RC_PROOF_CHECKS = [ 'evaos-workbench', 'com.evaos.workbench', 'broker login/session', + 'Fallback exact bundle identity verified: true', + 'Fallback exact main-process path verified: true', + 'Fallback exact main-process dwell seconds: 8', ], }, { @@ -955,6 +973,215 @@ function collectRcCanaryWorkflowIssues(workflow) { '.github/workflows/evaos-beta-rc-canary.yml: installed candidate must run the operator-acknowledged local control_start suite' ); } + const installedCandidateRun = runLines.join('\n'); + const installedProcessHelperStart = installedCandidateRun.indexOf('write_exact_app_process_pids() {'); + const installedProcessHelperEnd = installedCandidateRun.indexOf( + 'terminate_exact_app_processes() {', + installedProcessHelperStart + ); + const installedProcessHelper = + installedProcessHelperStart >= 0 && installedProcessHelperEnd > installedProcessHelperStart + ? installedCandidateRun.slice(installedProcessHelperStart, installedProcessHelperEnd) + : ''; + const connectorCleanupStart = installedCandidateRun.indexOf('connector_job_is_active() {'); + const connectorCleanupEnd = installedCandidateRun.indexOf( + "trap 'cleanup_candidate_processes $?' EXIT", + connectorCleanupStart + ); + const connectorCleanup = + connectorCleanupStart >= 0 && connectorCleanupEnd > connectorCleanupStart + ? installedCandidateRun.slice(connectorCleanupStart, connectorCleanupEnd) + : ''; + const connectorKillIndex = connectorCleanup.indexOf('kill -9 "$CONNECTOR_PID" >/dev/null 2>&1 || true'); + const connectorWaitIndex = connectorCleanup.indexOf('wait "$CONNECTOR_PID" >/dev/null 2>&1'); + const connectorSuccessIndex = connectorCleanup.indexOf('RC_CONNECTOR_CLEANUP_SUCCEEDED=true'); + const trapIndex = installedCandidateRun.indexOf("trap 'cleanup_candidate_processes $?' EXIT"); + const serveIndex = installedCandidateRun.indexOf('"$BRIDGE_COMMAND" serve \\'); + const deadlineIndex = installedCandidateRun.indexOf('CONNECTOR_DEADLINE=$((SECONDS + 45))'); + const tokenReadIndex = installedCandidateRun.indexOf( + 'CONNECTOR_TOKEN=$(read_connector_token_atomically 2>/dev/null)' + ); + const atomicTokenGateStart = installedCandidateRun.indexOf('ATOMIC_TOKEN_EXIT=1', deadlineIndex); + const connectorClassificationStart = installedCandidateRun.indexOf( + 'if [ "$TOKEN_EXISTS" != true ]; then', + atomicTokenGateStart + ); + const atomicTokenGate = + atomicTokenGateStart >= 0 && connectorClassificationStart > atomicTokenGateStart + ? installedCandidateRun.slice(atomicTokenGateStart, connectorClassificationStart) + : ''; + if ( + trapIndex < 0 || + serveIndex < 0 || + deadlineIndex < 0 || + tokenReadIndex < 0 || + trapIndex > serveIndex || + serveIndex > deadlineIndex || + deadlineIndex > tokenReadIndex || + tokenReadIndex > connectorClassificationStart || + atomicTokenGate.includes('CONNECTOR_HEALTH_REACHABLE') || + !installedCandidateRun.includes('TOKEN_FILE="$CONNECTOR_STATE_DIR/connector.token"') || + !installedCandidateRun.includes('EVAOS_DESKTOP_BRIDGE_STATE_DIR="$CONNECTOR_STATE_DIR" \\') || + !installedCandidateRun.includes('EVAOS_DESKTOP_BRIDGE_MANAGED_BY=workbench-session \\') || + !installedCandidateRun.includes('CONNECTOR_PID=$!') || + !connectorCleanup.includes( + 'CONNECTOR_JOB_PROBE_FAILED=false\nif ! job_snapshot=$(jobs -p); then\nCONNECTOR_JOB_PROBE_FAILED=true\nreturn 1\nfi' + ) || + !connectorCleanup.includes( + 'if connector_job_is_active; then\nkill "$CONNECTOR_PID" >/dev/null 2>&1 || true\nelif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then\ncleanup_failed=1\nfi' + ) || + !connectorCleanup.includes( + 'if connector_job_is_active; then\ncleanup_failed=1\nelif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then\ncleanup_failed=1\nelse\nset +e\nwait "$CONNECTOR_PID" >/dev/null 2>&1\nset -e\nfi' + ) || + connectorKillIndex < 0 || + connectorWaitIndex <= connectorKillIndex || + connectorSuccessIndex <= connectorWaitIndex || + !connectorCleanup.includes('set +e\nwait "$CONNECTOR_PID" >/dev/null 2>&1\nset -e') || + !installedCandidateRun.includes('stat -f \'%Lp\' "$TOKEN_FILE"') || + !installedCandidateRun.includes('const noFollow = fs.constants.O_NOFOLLOW;') || + !installedCandidateRun.includes('const before = fs.fstatSync(descriptor);') || + !installedCandidateRun.includes('const buffer = Buffer.alloc(130);') || + !installedCandidateRun.includes('const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0);') || + installedCandidateRun.includes("fs.readFileSync(descriptor, 'utf8')") || + !installedCandidateRun.includes('/bin/ps -ww -axo pid=,comm=') || + !installedProcessHelper.includes('if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then') || + !installedProcessHelper.includes('if node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'') || + installedProcessHelper.split('return 1').length - 1 < 2 || + !installedCandidateRun.includes('terminate_exact_app_processes "$BETA_APP"') || + installedCandidateRun.includes('pkill -f "EvaOSWorkbench|evaOS Workbench"') || + installedCandidateRun.includes('pgrep -f "EvaOSWorkbench|evaOS Workbench"') || + !installedCandidateRun.includes('/bin/ps -ww -axo pid=,comm= > "$WORKBENCH_PROCESS_SNAPSHOT"') || + !installedCandidateRun.includes("const canonicalApp = '/Applications/evaOS Workbench.app';") || + !installedCandidateRun.includes('if (match && match[1] !== canonicalApp) process.exit(2);') || + !installedCandidateRun.includes('if (canonicalMainCount === 0) process.exit(4);') || + !installedCandidateRun.includes('CONNECTOR_READINESS_CLASSIFICATION=health_unreachable') || + !installedCandidateRun.includes('CONNECTOR_TOKEN=""\nunset CONNECTOR_TOKEN') || + installedCandidateRun.includes('connector-service start') + ) { + issues.push( + '.github/workflows/evaos-beta-rc-canary.yml: installed connector proof must start the packaged bridge in an isolated harness before token polling and terminate only its captured child' + ); + } + if ( + installedCandidateRun.includes('$PROOF_DIR/installed-candidate-pre-canary.stdout') || + installedCandidateRun.includes('$PROOF_DIR/installed-candidate-pre-canary.stderr') || + installedCandidateRun.includes('$PROOF_DIR/installed-candidate-connector.stdout') || + installedCandidateRun.includes('$PROOF_DIR/installed-candidate-connector.stderr') + ) { + issues.push( + '.github/workflows/evaos-beta-rc-canary.yml: raw installed-bridge stdout and stderr must remain outside uploaded RC proof directories' + ); + } + } + const rollbackSteps = getWorkflowNamedStepBlocks(workflow, 'Roll back beta and verify fallback'); + const rollbackIfValues = rollbackSteps.length === 1 ? getWorkflowStepScalarValues(rollbackSteps[0], 'if') : []; + const installSteps = getWorkflowNamedStepBlocks(workflow, 'Install fallback and beta apps'); + const installRun = + installSteps.length === 1 + ? getExecutableBlockLines(getWorkflowStepPropertyBlock(installSteps[0], 'run', true)).join('\n') + : ''; + const mutationMarkerIndex = installRun.indexOf('echo "mutation_started=true" >> "$GITHUB_OUTPUT"'); + const fallbackInstallIndex = installRun.indexOf('install_fallback_app "$FALLBACK_ASSET" "$FALLBACK_APP_NAME"'); + const rollbackRun = + rollbackSteps.length === 1 + ? getExecutableBlockLines(getWorkflowStepPropertyBlock(rollbackSteps[0], 'run', true)).join('\n') + : ''; + const fallbackDwellIndex = rollbackRun.indexOf('FALLBACK_LAUNCH_DWELL_SECONDS=8'); + const fallbackVerifiedIndex = rollbackRun.indexOf('echo "RC_FALLBACK_LAUNCH_VERIFIED=true" >> "$GITHUB_ENV"'); + const rollbackProcessHelperStart = rollbackRun.indexOf('write_exact_app_process_pids() {'); + const rollbackMainHelperStart = rollbackRun.indexOf('write_exact_app_main_pids() {', rollbackProcessHelperStart); + const rollbackTerminateHelperStart = rollbackRun.indexOf( + 'terminate_exact_app_processes() {', + rollbackMainHelperStart + ); + const rollbackProcessHelper = + rollbackProcessHelperStart >= 0 && rollbackMainHelperStart > rollbackProcessHelperStart + ? rollbackRun.slice(rollbackProcessHelperStart, rollbackMainHelperStart) + : ''; + const rollbackMainHelper = + rollbackMainHelperStart >= 0 && rollbackTerminateHelperStart > rollbackMainHelperStart + ? rollbackRun.slice(rollbackMainHelperStart, rollbackTerminateHelperStart) + : ''; + if ( + installSteps.length !== 1 || + mutationMarkerIndex < 0 || + fallbackInstallIndex < 0 || + mutationMarkerIndex > fallbackInstallIndex || + rollbackSteps.length !== 1 || + rollbackIfValues.length !== 1 || + rollbackIfValues[0] !== "${{ always() && steps.install_apps.outputs.mutation_started == 'true' }}" || + !rollbackSteps[0].includes('INSTALL_STEP_OUTCOME: ${{ steps.install_apps.outcome }}') || + !rollbackSteps[0].includes('[ "$INSTALL_STEP_OUTCOME" != "success" ]') || + !installRun.includes('FALLBACK_EXPECTED_BUNDLE_ID=$(/usr/libexec/PlistBuddy') || + !installRun.includes('FALLBACK_EXPECTED_EXECUTABLE=$(/usr/libexec/PlistBuddy') || + !rollbackRun.includes('terminate_exact_app_processes "$BETA_APP"') || + !rollbackRun.includes('terminate_exact_app_processes "$FALLBACK_APP"') || + !rollbackRun.includes('/bin/ps -ww -axo pid=,comm=') || + !rollbackProcessHelper.includes('if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then') || + !rollbackProcessHelper.includes('if node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'') || + rollbackProcessHelper.split('return 1').length - 1 < 2 || + !rollbackMainHelper.includes('if ! /bin/ps -ww -axo pid=,comm= > "$process_snapshot"; then') || + !rollbackMainHelper.includes( + 'if node - "$process_snapshot" "$app_path" "$executable_name" "$pid_output" <<\'NODE\'' + ) || + rollbackMainHelper.split('return 1').length - 1 < 2 || + !rollbackRun.includes('kill -9 "$process_id"') || + !rollbackRun.includes('write_exact_app_main_pids \\') || + !rollbackRun.includes('ACTUAL_FALLBACK_BUNDLE_ID=$(/usr/libexec/PlistBuddy') || + !rollbackRun.includes('RC_WORKBENCH_CLEANUP_SUCCEEDED=true') || + fallbackDwellIndex < 0 || + fallbackVerifiedIndex < fallbackDwellIndex || + !rollbackRun.includes('/usr/bin/grep -Fx "$FALLBACK_LAUNCH_PID" "$FALLBACK_MAIN_PIDS"') || + rollbackRun.includes('pkill -f') || + rollbackRun.includes('pgrep -f') + ) { + issues.push('.github/workflows/evaos-beta-rc-canary.yml: rollback must run after every post-install outcome'); + } + const failureWriterSteps = getWorkflowNamedStepBlocks(workflow, 'Write sanitized RC failure packet'); + const failureUploadSteps = getWorkflowNamedStepBlocks(workflow, 'Upload sanitized RC failure packet'); + const expectedFailureIf = "${{ failure() && steps.prepare_proof.outcome == 'success' }}"; + const failureWriterIfValues = + failureWriterSteps.length === 1 ? getWorkflowStepScalarValues(failureWriterSteps[0], 'if') : []; + const failureUploadIfValues = + failureUploadSteps.length === 1 ? getWorkflowStepScalarValues(failureUploadSteps[0], 'if') : []; + const failureWriter = failureWriterSteps.length === 1 ? failureWriterSteps[0] : ''; + const failureWriterRun = getWorkflowStepPropertyBlock(failureWriter, 'run', true).replace(/\r\n/g, '\n'); + const failureWriterPropertyNames = getWorkflowStepPropertyNames(failureWriter).sort(); + const expectedFailureWriterPropertyNames = ['env', 'if', 'name', 'run'].sort(); + const expectedFailureWriterEnvKeys = ['INSTALL_MUTATION_STARTED', 'ROLLBACK_STEP_OUTCOME'].sort(); + const failureWriterEnvKeys = getWorkflowStepEnvKeys(failureWriter).sort(); + const exactFailureWriterContract = + JSON.stringify(failureWriterPropertyNames) === JSON.stringify(expectedFailureWriterPropertyNames) && + JSON.stringify(failureWriterEnvKeys) === JSON.stringify(expectedFailureWriterEnvKeys) && + getWorkflowStepEnvValues(failureWriter, 'INSTALL_MUTATION_STARTED').join('') === + '${{ steps.install_apps.outputs.mutation_started }}' && + getWorkflowStepEnvValues(failureWriter, 'ROLLBACK_STEP_OUTCOME').join('') === + '${{ steps.rollback_candidate.outcome }}' && + createHash('sha256').update(failureWriterRun).digest('hex') === RC_FAILURE_WRITER_RUN_SHA256; + const unsafeFailureWriter = + /\b(?:cp|mv|ditto|rsync|tar|zip|cat|tee|readFile|readFileSync|copyFile|copyFileSync|createReadStream|appendFile|appendFileSync)\b/.test( + failureWriter + ) || + /\$(?:PROOF_DIR|RUNNER_TEMP)|stdout|stderr|connector\.token|\/Applications\//i.test(failureWriter) || + /process\.env\.(?:FALLBACK_APP|FALLBACK_BUNDLE_ID|FALLBACK_EXECUTABLE|FALLBACK_LAUNCH_PID)/.test(failureWriter); + if ( + failureWriterSteps.length !== 1 || + failureUploadSteps.length !== 1 || + failureWriterIfValues.length !== 1 || + failureWriterIfValues[0] !== expectedFailureIf || + failureUploadIfValues.length !== 1 || + failureUploadIfValues[0] !== expectedFailureIf || + !failureWriter.includes("schema: 'evaos-beta-rc-sanitized-failure/v1'") || + !failureWriter.includes("workbenchCleanupSucceeded: strictBoolean('RC_WORKBENCH_CLEANUP_SUCCEEDED')") || + !failureWriter.includes("fallbackLaunchVerified: strictBoolean('RC_FALLBACK_LAUNCH_VERIFIED')") || + !exactFailureWriterContract || + unsafeFailureWriter || + !failureUploadSteps[0].includes('path: rc-failure-proof') || + failureUploadSteps[0].includes('path: rc-proof') + ) { + issues.push( + '.github/workflows/evaos-beta-rc-canary.yml: failures must upload only the allowlisted sanitized RC failure packet' + ); } return issues; } @@ -3770,6 +3997,52 @@ function assertRcInstalledCandidatePreCanaryProof(proofPath, tag, releaseManifes } } +function assertRcInstalledCandidateConnectorStartProof(proofPath) { + const proof = readManifestFile(proofPath); + const expectedTopLevelKeys = [ + 'attempts', + 'classification', + 'health', + 'mode', + 'ok', + 'processExitCode', + 'processRunning', + 'schema', + 'startInvoked', + 'token', + ]; + const expectedTokenKeys = ['atomicRead', 'exists', 'mode0600', 'nonempty', 'ownerMatchesRunner', 'regularFile']; + const expectedHealthKeys = ['reachable']; + if ( + JSON.stringify(Object.keys(proof).sort()) !== JSON.stringify(expectedTopLevelKeys) || + !proof.token || + typeof proof.token !== 'object' || + JSON.stringify(Object.keys(proof.token).sort()) !== JSON.stringify(expectedTokenKeys) || + !proof.health || + typeof proof.health !== 'object' || + JSON.stringify(Object.keys(proof.health).sort()) !== JSON.stringify(expectedHealthKeys) || + proof.schema !== 'evaos-installed-connector-harness-start/v1' || + proof.ok !== true || + proof.classification !== 'ready' || + proof.mode !== 'harness-owned-loopback' || + proof.startInvoked !== true || + proof.processRunning !== true || + proof.processExitCode !== null || + !Number.isInteger(proof.attempts) || + proof.attempts < 1 || + proof.attempts > 100 || + proof.token.atomicRead !== true || + proof.token.exists !== true || + proof.token.regularFile !== true || + proof.token.ownerMatchesRunner !== true || + proof.token.mode0600 !== true || + proof.token.nonempty !== true || + proof.health.reachable !== true + ) { + throw new Error('Installed candidate connector start proof must be the strict successful harness summary.'); + } +} + function assertRcInstalledCandidateConnectorProof(proofPath, tag, releaseManifest) { const proof = readManifestFile(proofPath); const expectedVersion = versionFromPublicBetaTag(tag); @@ -3955,6 +4228,8 @@ function verifyRcProof(proofDir, tag, env = process.env) { assertRcUpdaterZipTrustProof(filePath, tag, trustedManifest, resolvedReleaseAssetsDir, releaseAssetBytesDir); } else if (required.id === 'installed-candidate-pre-canary') { assertRcInstalledCandidatePreCanaryProof(filePath, tag, trustedManifest); + } else if (required.id === 'installed-candidate-connector-start') { + assertRcInstalledCandidateConnectorStartProof(filePath); } else if (required.id === 'installed-candidate-connector') { assertRcInstalledCandidateConnectorProof(filePath, tag, trustedManifest); } diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index a0b4c2c8c9..8a4a849ecd 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -57,6 +57,107 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour bridgeWrapperScript: () => string; directorySha256: (sourceDir: string) => string; }; + +function extractRcConnectorCleanupScript(workflow: string): string { + const start = workflow.indexOf(' connector_job_is_active() {'); + const end = workflow.indexOf("\n trap 'cleanup_candidate_processes $?' EXIT", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return workflow + .slice(start, end) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); +} + +function rcConnectorCleanupProbe(cleanupScript: string): string { + return `set -euo pipefail +jobs() { + probe_count=0 + if [ -f "$JOB_PROBE_COUNT_FILE" ]; then + probe_count=$(< "$JOB_PROBE_COUNT_FILE") + fi + probe_count=$((probe_count + 1)) + printf '%s\\n' "$probe_count" > "$JOB_PROBE_COUNT_FILE" + if [ "$FAKE_JOB_PROBE_ERROR" = "true" ] || \ + { [ "$FAKE_JOB_PROBE_ERROR_AT" -gt 0 ] && [ "$probe_count" -eq "$FAKE_JOB_PROBE_ERROR_AT" ]; }; then + return 1 + fi + if [ "$FAKE_JOB_ACTIVE" = "true" ]; then + printf '%s\\n' "$CONNECTOR_PID" + fi +} +kill() { printf '%s\\n' "$*" >> "$SIGNAL_LOG"; } +sleep() { :; } +wait() { + printf 'waited\\n' >> "$WAIT_LOG" + return 143 +} +terminate_exact_app_processes() { return 0; } +${cleanupScript} +cleanup_candidate_processes 0 +`; +} + +function rcConnectorCleanupEnv( + dir: string, + githubEnv: string, + waitLog: string, + signalLog: string, + active: boolean, + probeError = false, + probeErrorAt = 0 +): NodeJS.ProcessEnv { + return { + ...process.env, + BETA_APP: '/Applications/evaOS Workbench.app', + CONNECTOR_CANARY_STDERR: path.join(dir, 'connector-canary.stderr'), + CONNECTOR_CANARY_STDOUT: path.join(dir, 'connector-canary.stdout'), + CONNECTOR_PID: '4242', + CONNECTOR_START_STDERR: path.join(dir, 'connector-start.stderr'), + CONNECTOR_START_STDOUT: path.join(dir, 'connector-start.stdout'), + CONNECTOR_STATE_DIR: path.join(dir, 'state'), + FAKE_JOB_ACTIVE: active ? 'true' : 'false', + FAKE_JOB_PROBE_ERROR: probeError ? 'true' : 'false', + FAKE_JOB_PROBE_ERROR_AT: String(probeErrorAt), + GITHUB_ENV: githubEnv, + JOB_PROBE_COUNT_FILE: path.join(dir, 'job-probe-count'), + PRE_CANARY_STDERR: path.join(dir, 'pre-canary.stderr'), + PRE_CANARY_STDOUT: path.join(dir, 'pre-canary.stdout'), + RUNNER_TEMP: dir, + SIGNAL_LOG: signalLog, + WAIT_LOG: waitLog, + WORKBENCH_PROCESS_SNAPSHOT: path.join(dir, 'workbench-processes.txt'), + }; +} + +function extractRcConnectorClassificationScript(workflow: string): string { + const start = workflow.indexOf( + ' read_connector_token_state\n CONNECTOR_TOKEN=""\n ATOMIC_TOKEN_EXIT=1' + ); + const end = workflow.indexOf('\n publish_connector_state', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return workflow + .slice(start, end) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); +} + +function rcConnectorClassificationProbe(classificationScript: string): string { + return `set -euo pipefail +read_connector_token_state() { :; } +read_connector_token_atomically() { + if [ "$FAKE_ATOMIC_TOKEN_VALID" != "true" ]; then + return 2 + fi + printf '%s' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +} +${classificationScript} +printf '%s\\n' "$CONNECTOR_READINESS_CLASSIFICATION" +`; +} const afterSign = require('../../../scripts/afterSign.js') as { (context: unknown): Promise; default: (context: unknown) => Promise; @@ -272,6 +373,32 @@ function writeArm64TrustEvidence(proofDir: string) { 2 )}\n` ); + fs.writeFileSync( + path.join(proofDir, 'installed-candidate-connector-start.json'), + `${JSON.stringify( + { + schema: 'evaos-installed-connector-harness-start/v1', + ok: true, + classification: 'ready', + mode: 'harness-owned-loopback', + startInvoked: true, + processRunning: true, + processExitCode: null, + attempts: 1, + token: { + atomicRead: true, + exists: true, + regularFile: true, + ownerMatchesRunner: true, + mode0600: true, + nonempty: true, + }, + health: { reachable: true }, + }, + null, + 2 + )}\n` + ); fs.writeFileSync( path.join(proofDir, 'installed-candidate-connector.json'), `${JSON.stringify( @@ -1074,8 +1201,12 @@ describe('evaOS beta release gate', () => { 'utf8' ); - expect(workflow).toContain('2> "$PROOF_DIR/installed-candidate-pre-canary.stderr.txt"'); - expect(workflow).toContain('2> "$PROOF_DIR/installed-candidate-connector.stderr.txt"'); + expect(workflow).toContain('PRE_CANARY_STDERR="$RUNNER_TEMP/evaos-installed-candidate-pre-canary.stderr.txt"'); + expect(workflow).toContain('CONNECTOR_CANARY_STDERR="$RUNNER_TEMP/evaos-installed-candidate-connector.stderr.txt"'); + expect(workflow).toContain('2> "$PRE_CANARY_STDERR"'); + expect(workflow).toContain('2> "$CONNECTOR_CANARY_STDERR"'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-pre-canary.stderr'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-connector.stderr'); expect(workflow).toContain('LC_ALL=C grep -R -F -- "$CONNECTOR_TOKEN" "$PROOF_DIR"'); expect(workflow).toMatch(/QA_CANARY_EXIT=\$\?[\s\S]*unset CONNECTOR_TOKEN[\s\S]*QA_CANARY_EXIT/); expect(workflow).toContain('EVAOS_BETA_RC_RELEASE_ASSETS_DIR: release-assets'); @@ -1142,6 +1273,12 @@ describe('evaOS beta release gate', () => { '.github/workflows/evaos-beta-rc-canary.yml: install_app_from_dmg must not reference the ZIP-only extract_dir variable under nounset'; const controlStartIssue = '.github/workflows/evaos-beta-rc-canary.yml: installed candidate must run the operator-acknowledged local control_start suite'; + const harnessIssue = + '.github/workflows/evaos-beta-rc-canary.yml: installed connector proof must start the packaged bridge in an isolated harness before token polling and terminate only its captured child'; + const rollbackIssue = + '.github/workflows/evaos-beta-rc-canary.yml: rollback must run after every post-install outcome'; + const failurePacketIssue = + '.github/workflows/evaos-beta-rc-canary.yml: failures must upload only the allowlisted sanitized RC failure packet'; expect(releaseGate.collectRcCanaryWorkflowIssues(workflow)).toEqual([]); expect(workflow).toContain("fs.writeFileSync(outputPath, String(asset.sha256).toLowerCase(), 'utf8');"); @@ -1176,6 +1313,208 @@ describe('evaOS beta release gate', () => { expect( releaseGate.collectRcCanaryWorkflowIssues(workflow.replace(' --operator-ack-live-control \\\n', '')) ).toContain(controlStartIssue); + expect(releaseGate.collectRcCanaryWorkflowIssues(workflow.replace(' CONNECTOR_PID=$!\n', ''))).toContain( + harnessIssue + ); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' CONNECTOR_JOB_PROBE_FAILED=false\n' + + ' if ! job_snapshot=$(jobs -p); then\n' + + ' CONNECTOR_JOB_PROBE_FAILED=true\n' + + ' return 1\n' + + ' fi\n', + ' if ! job_snapshot=$(jobs -p); then\n return 0\n fi\n' + ) + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' wait "$CONNECTOR_PID" >/dev/null 2>&1\n', '') + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' elif [ "$CONNECTOR_JOB_PROBE_FAILED" = true ]; then\n' + + ' cleanup_failed=1\n' + + ' else\n' + + ' set +e\n' + + ' wait "$CONNECTOR_PID" >/dev/null 2>&1\n', + ' else\n set +e\n wait "$CONNECTOR_PID" >/dev/null 2>&1\n' + ) + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' [ "$TOKEN_MODE_600" = true ] && [ "$TOKEN_NONEMPTY" = true ]; then\n', + ' [ "$TOKEN_MODE_600" = true ] && [ "$TOKEN_NONEMPTY" = true ] && \\\n' + + ' [ "$CONNECTOR_HEALTH_REACHABLE" = true ]; then\n' + ) + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues(workflow.replace(' unset CONNECTOR_TOKEN\n', '')) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues(workflow.replace(' const buffer = Buffer.alloc(130);\n', '')) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0);\n', + " const raw = fs.readFileSync(descriptor, 'utf8');\n" + ) + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace('/bin/ps -ww -axo pid=,comm=', 'ps -axo pid=,command=') + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace('/bin/ps -ww -axo pid=,comm= > "$WORKBENCH_PROCESS_SNAPSHOT"', 'ps -axo pid=,command=') + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' if node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'\n', + ' node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'\n' + ) + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' if (match && match[1] !== canonicalApp) process.exit(2);\n', '') + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' if (canonicalMainCount === 0) process.exit(4);\n', '') + ) + ).toContain(harnessIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + " if: ${{ always() && steps.install_apps.outputs.mutation_started == 'true' }}\n", + " if: ${{ success() && steps.install_apps.outputs.mutation_started == 'true' }}\n" + ) + ) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' echo "mutation_started=true" >> "$GITHUB_OUTPUT"\n', '') + ) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace('[ "$INSTALL_STEP_OUTCOME" != "success" ]', '[ "$INSTALL_STEP_OUTCOME" = "success" ]') + ) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' echo "RC_FALLBACK_LAUNCH_VERIFIED=true" >> "$GITHUB_ENV"\n', '') + ) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues(workflow.replace(' FALLBACK_LAUNCH_DWELL_SECONDS=8\n', '')) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace('/usr/bin/grep -Fx "$FALLBACK_LAUNCH_PID" "$FALLBACK_MAIN_PIDS"', '/usr/bin/true') + ) + ).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' echo "RC_WORKBENCH_CLEANUP_SUCCEEDED=true" >> "$GITHUB_ENV"\n\n rm -rf "$BETA_APP"', + ' pkill -f "evaOS Workbench" || true\n\n rm -rf "$BETA_APP"' + ) + ) + ).toContain(rollbackIssue); + const rollbackStepIndex = workflow.indexOf(' - name: Roll back beta and verify fallback'); + const rollbackParserMutation = `${workflow.slice(0, rollbackStepIndex)}${workflow + .slice(rollbackStepIndex) + .replace( + ' if node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'\n', + ' node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\'\n' + )}`; + expect(releaseGate.collectRcCanaryWorkflowIssues(rollbackParserMutation)).toContain(rollbackIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace(' path: rc-failure-proof\n', ' path: rc-proof\n') + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + " if: ${{ failure() && steps.prepare_proof.outcome == 'success' }}\n", + ' if: ${{ success() }}\n' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + " if: ${{ failure() && steps.prepare_proof.outcome == 'success' }}\n", + " if: ${{ failure() && steps.prepare_proof.outcome == 'success' }}\n continue-on-error: true\n" + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' mkdir -p rc-failure-proof\n', + ' mkdir -p rc-failure-proof\n cp "$RUNNER_TEMP/raw.stderr" rc-failure-proof/raw.stderr\n' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' set -euo pipefail\n rm -rf rc-failure-proof\n', + ' set -euo pipefail\n' + + ' # ${{ github.event.inputs.tag }}\n' + + ' rm -rf rc-failure-proof\n' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + " workbenchCleanupSucceeded: strictBoolean('RC_WORKBENCH_CLEANUP_SUCCEEDED'),\n", + '' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' rollback: {\n', + ' rollback: {\n fallbackPid: process.env.FALLBACK_LAUNCH_PID,\n' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' rollback: {\n', + ' rollback: {\n secret: process.env.GH_TOKEN,\n' + ) + ) + ).toContain(failurePacketIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + workflow.replace( + ' INSTALL_MUTATION_STARTED: ${{ steps.install_apps.outputs.mutation_started }}\n', + ' INSTALL_MUTATION_STARTED: ${{ steps.install_apps.outputs.mutation_started }}\n' + + ' GH_TOKEN: ${{ secrets.GH_TOKEN }}\n' + ) + ) + ).toContain(failurePacketIssue); const installers = Array.from( workflow.matchAll(/^ {10}install_app_from_dmg\(\) \{\n[\s\S]*?^ {10}\}$/gm), @@ -1206,6 +1545,138 @@ printf '%s\\n' ok } }); + it('reaps an inactive captured connector job before reporting cleanup success', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const cleanupScript = extractRcConnectorCleanupScript(workflow); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-connector-cleanup-reap-')); + const githubEnv = path.join(dir, 'github.env'); + const waitLog = path.join(dir, 'wait.log'); + const signalLog = path.join(dir, 'signal.log'); + fs.mkdirSync(path.join(dir, 'state')); + fs.writeFileSync(githubEnv, ''); + fs.writeFileSync(waitLog, ''); + fs.writeFileSync(signalLog, ''); + try { + const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', rcConnectorCleanupProbe(cleanupScript)], { + encoding: 'utf8', + env: rcConnectorCleanupEnv(dir, githubEnv, waitLog, signalLog, false), + }); + + expect(result.status).toBe(0); + expect(fs.readFileSync(waitLog, 'utf8')).toBe('waited\n'); + expect(fs.readFileSync(signalLog, 'utf8')).toBe(''); + expect(fs.readFileSync(githubEnv, 'utf8')).toContain('RC_CONNECTOR_CLEANUP_SUCCEEDED=true'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails connector cleanup without waiting when the captured job survives SIGKILL', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const cleanupScript = extractRcConnectorCleanupScript(workflow); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-connector-cleanup-stuck-')); + const githubEnv = path.join(dir, 'github.env'); + const waitLog = path.join(dir, 'wait.log'); + const signalLog = path.join(dir, 'signal.log'); + fs.mkdirSync(path.join(dir, 'state')); + fs.writeFileSync(githubEnv, ''); + fs.writeFileSync(waitLog, ''); + fs.writeFileSync(signalLog, ''); + try { + const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', rcConnectorCleanupProbe(cleanupScript)], { + encoding: 'utf8', + env: rcConnectorCleanupEnv(dir, githubEnv, waitLog, signalLog, true), + }); + + expect(result.status).toBe(1); + expect(fs.readFileSync(waitLog, 'utf8')).toBe(''); + expect(fs.readFileSync(signalLog, 'utf8')).toContain('-9 4242'); + expect(fs.readFileSync(githubEnv, 'utf8')).toContain('RC_CONNECTOR_CLEANUP_SUCCEEDED=false'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails connector cleanup boundedly when the job ownership probe errors', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const cleanupScript = extractRcConnectorCleanupScript(workflow); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-connector-cleanup-probe-error-')); + const githubEnv = path.join(dir, 'github.env'); + const waitLog = path.join(dir, 'wait.log'); + const signalLog = path.join(dir, 'signal.log'); + fs.mkdirSync(path.join(dir, 'state')); + fs.writeFileSync(githubEnv, ''); + fs.writeFileSync(waitLog, ''); + fs.writeFileSync(signalLog, ''); + try { + const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', rcConnectorCleanupProbe(cleanupScript)], { + encoding: 'utf8', + env: rcConnectorCleanupEnv(dir, githubEnv, waitLog, signalLog, false, true), + }); + + expect(result.status).toBe(1); + expect(fs.readFileSync(waitLog, 'utf8')).toBe(''); + expect(fs.readFileSync(signalLog, 'utf8')).toBe(''); + expect(fs.readFileSync(githubEnv, 'utf8')).toContain('RC_CONNECTOR_CLEANUP_SUCCEEDED=false'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails connector cleanup without signaling or waiting when the final ownership probe errors', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const cleanupScript = extractRcConnectorCleanupScript(workflow); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-connector-cleanup-final-probe-error-')); + const githubEnv = path.join(dir, 'github.env'); + const waitLog = path.join(dir, 'wait.log'); + const signalLog = path.join(dir, 'signal.log'); + fs.mkdirSync(path.join(dir, 'state')); + fs.writeFileSync(githubEnv, ''); + fs.writeFileSync(waitLog, ''); + fs.writeFileSync(signalLog, ''); + try { + const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', rcConnectorCleanupProbe(cleanupScript)], { + encoding: 'utf8', + env: rcConnectorCleanupEnv(dir, githubEnv, waitLog, signalLog, false, false, 5), + }); + + expect(result.status).toBe(1); + expect(fs.readFileSync(path.join(dir, 'job-probe-count'), 'utf8')).toBe('5\n'); + expect(fs.readFileSync(waitLog, 'utf8')).toBe(''); + expect(fs.readFileSync(signalLog, 'utf8')).toBe(''); + expect(fs.readFileSync(githubEnv, 'utf8')).toContain('RC_CONNECTOR_CLEANUP_SUCCEEDED=false'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('distinguishes an unreachable connector health endpoint from an invalid token', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const classificationScript = extractRcConnectorClassificationScript(workflow); + const runClassification = (atomicTokenValid: boolean) => + spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', rcConnectorClassificationProbe(classificationScript)], { + encoding: 'utf8', + env: { + ...process.env, + CONNECTOR_HEALTH_REACHABLE: 'false', + FAKE_ATOMIC_TOKEN_VALID: atomicTokenValid ? 'true' : 'false', + TOKEN_EXISTS: 'true', + TOKEN_MODE_600: 'true', + TOKEN_NONEMPTY: 'true', + TOKEN_OWNER_MATCH: 'true', + TOKEN_REGULAR: 'true', + }, + }); + + const validToken = runClassification(true); + expect(validToken.status).toBe(0); + expect(validToken.stdout.trim()).toBe('health_unreachable'); + + const invalidToken = runClassification(false); + expect(invalidToken.status).toBe(0); + expect(invalidToken.stdout.trim()).toBe('token_invalid'); + }); + it('recognizes little-endian fat Mach-O helpers during signing closure validation', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-after-sign-fat-mach-o-')); try { @@ -3701,7 +4172,12 @@ printf '%s\\n' ok ); fs.writeFileSync( path.join(proofDir, 'rollback-smoke.md'), - 'PASS: candidate app rolled back; released fallback app launched; data/cache disposition recorded; protocol handler state evaos-workbench / com.evaos.workbench inspected; broker login/session state remained usable.\n' + [ + 'PASS: candidate app rolled back; released fallback app launched; data/cache disposition recorded; protocol handler state evaos-workbench / com.evaos.workbench inspected; broker login/session state remained usable.', + 'Fallback exact bundle identity verified: true', + 'Fallback exact main-process path verified: true', + 'Fallback exact main-process dwell seconds: 8', + ].join('\n') + '\n' ); fs.writeFileSync( path.join(proofDir, 'support-notes.md'), @@ -3718,6 +4194,54 @@ printf '%s\\n' ok }) ).toBe(true); + const rollbackProofPath = path.join(proofDir, 'rollback-smoke.md'); + const rollbackProof = fs.readFileSync(rollbackProofPath, 'utf8'); + for (const requiredMarker of [ + 'Fallback exact bundle identity verified: true', + 'Fallback exact main-process path verified: true', + 'Fallback exact main-process dwell seconds: 8', + ]) { + fs.writeFileSync(rollbackProofPath, rollbackProof.replace(`${requiredMarker}\n`, '')); + expect(() => + releaseGate.verifyRcProof(proofDir, tag, { + GITHUB_REPOSITORY: '100yenadmin/evaOS-GUI', + EXPECTED_RELEASE_COMMIT: fixtureReleaseCommit, + EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1', + EVAOS_RELEASE_TARGET_PLATFORMS: 'macos-arm64', + EVAOS_BETA_RC_RELEASE_ASSETS_DIR: releaseAssetBytesDir, + }) + ).toThrow(/rollback-smoke/i); + } + fs.writeFileSync(rollbackProofPath, rollbackProof); + + const connectorStartProofPath = path.join(proofDir, 'installed-candidate-connector-start.json'); + const connectorStartProof = JSON.parse(fs.readFileSync(connectorStartProofPath, 'utf8')); + connectorStartProof.token.mode0600 = false; + fs.writeFileSync(connectorStartProofPath, `${JSON.stringify(connectorStartProof, null, 2)}\n`); + expect(() => + releaseGate.verifyRcProof(proofDir, tag, { + GITHUB_REPOSITORY: '100yenadmin/evaOS-GUI', + EXPECTED_RELEASE_COMMIT: fixtureReleaseCommit, + EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1', + EVAOS_RELEASE_TARGET_PLATFORMS: 'macos-arm64', + EVAOS_BETA_RC_RELEASE_ASSETS_DIR: releaseAssetBytesDir, + }) + ).toThrow(/mode0600|strict successful harness summary/); + connectorStartProof.token.mode0600 = true; + connectorStartProof.rawMessage = 'Bearer secret https://private.example /Users/private/connector.token'; + fs.writeFileSync(connectorStartProofPath, `${JSON.stringify(connectorStartProof, null, 2)}\n`); + expect(() => + releaseGate.verifyRcProof(proofDir, tag, { + GITHUB_REPOSITORY: '100yenadmin/evaOS-GUI', + EXPECTED_RELEASE_COMMIT: fixtureReleaseCommit, + EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1', + EVAOS_RELEASE_TARGET_PLATFORMS: 'macos-arm64', + EVAOS_BETA_RC_RELEASE_ASSETS_DIR: releaseAssetBytesDir, + }) + ).toThrow(/strict successful harness summary/); + delete connectorStartProof.rawMessage; + fs.writeFileSync(connectorStartProofPath, `${JSON.stringify(connectorStartProof, null, 2)}\n`); + const updaterMetadataPath = path.join(proofDir, 'release-assets', 'latest-arm64-mac.yml'); const updaterMetadata = fs.readFileSync(updaterMetadataPath, 'utf8'); fs.writeFileSync(updaterMetadataPath, "minimumSystemVersion: '24.0.0'\npath: different-mac-arm64.zip\n"); diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index bba112d5ea..a43898262b 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -266,6 +266,45 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('keeps the installed QA child on the same isolated connector state directory', () => { + const sourceDir = join(process.cwd(), 'resources', 'evaos-beta', 'bridge', 'src'); + const script = [ + 'import os', + 'from pathlib import Path', + 'from tempfile import TemporaryDirectory', + 'from types import SimpleNamespace', + 'from evaos_desktop_bridge import qa_canary', + 'with TemporaryDirectory() as root:', + ' launcher = Path(root) / "evaos-desktop-bridge"', + ' launcher.write_text("#!/bin/sh\\nexit 0\\n", encoding="utf-8")', + ' launcher.chmod(0o755)', + ' launcher = launcher.resolve()', + ' qa_canary.INSTALLED_WORKBENCH_BRIDGE_CLI = launcher', + ' captured = {}', + ' def fake_run(argv, **kwargs):', + ' captured["argv"] = argv', + ' captured["env"] = kwargs["env"]', + ' return SimpleNamespace(returncode=0, stdout="{}\\n")', + ' qa_canary.subprocess.run = fake_run', + ' state_dir = str(Path(root) / "isolated-state")', + ' os.environ["EVAOS_DESKTOP_BRIDGE_STATE_DIR"] = state_dir', + ' os.environ["EVAOS_UNRELATED_SECRET"] = "must-not-propagate"', + ' exit_code, output = qa_canary._run_local_workbench_cli(launcher, ["status", "--json"], 5)', + ' assert exit_code == 0 and output == "{}\\n"', + ' assert captured["argv"] == [str(launcher), "status", "--json"]', + ' assert captured["env"]["EVAOS_DESKTOP_BRIDGE_STATE_DIR"] == state_dir', + ' assert "EVAOS_UNRELATED_SECRET" not in captured["env"]', + 'print("ok")', + ].join('\n'); + + expect( + execFileSync('python3', ['-B', '-c', script], { + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONPATH: sourceDir }, + }).trim() + ).toBe('ok'); + }); + it('captures pre-canary failures as sanitized check summaries before preserving the exit code', () => { const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'evaos-beta-rc-canary.yml'), 'utf8'); const sanitizerCommand = 'node - "$PRE_CANARY_REPORT" <<\'NODE\''; @@ -279,7 +318,7 @@ describe('prepareEvaosDesktopBridgeResource', () => { .join('\n'); const failureBlock = workflow.slice( workflow.indexOf('PRE_CANARY_EXIT=$?'), - workflow.indexOf('TOKEN_FILE="$HOME/Library/Application Support/evaos-desktop-bridge/connector.token"') + workflow.indexOf('write_connector_start_summary() {') ); expect(failureBlock).toContain('PRE_CANARY_EXIT=$?'); @@ -343,6 +382,418 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('starts the installed connector in an isolated loopback harness before token proof and sanitizes failures', () => { + const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'evaos-beta-rc-canary.yml'), 'utf8'); + const trapIndex = workflow.indexOf("trap 'cleanup_candidate_processes $?' EXIT"); + const serveIndex = workflow.indexOf('"$BRIDGE_COMMAND" serve \\'); + const deadlineIndex = workflow.indexOf('CONNECTOR_DEADLINE=$((SECONDS + 45))'); + const tokenReadIndex = workflow.indexOf('CONNECTOR_TOKEN=$(read_connector_token_atomically 2>/dev/null)'); + const qaIndex = workflow.indexOf('"$BRIDGE_COMMAND" qa-canary \\'); + + expect(trapIndex).toBeGreaterThan(-1); + expect(trapIndex).toBeLessThan(serveIndex); + expect(serveIndex).toBeLessThan(deadlineIndex); + expect(deadlineIndex).toBeLessThan(tokenReadIndex); + expect(tokenReadIndex).toBeLessThan(qaIndex); + expect(workflow).toContain('TOKEN_FILE="$CONNECTOR_STATE_DIR/connector.token"'); + expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_STATE_DIR="$CONNECTOR_STATE_DIR" \\'); + expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_MANAGED_BY=workbench-session \\'); + expect(workflow).toContain('CONNECTOR_PID=$!'); + expect(workflow).toContain('kill "$CONNECTOR_PID" >/dev/null 2>&1 || true'); + expect(workflow).toContain('stat -f \'%Lp\' "$TOKEN_FILE"'); + expect(workflow).toContain('const noFollow = fs.constants.O_NOFOLLOW;'); + expect(workflow).toContain('const before = fs.fstatSync(descriptor);'); + expect(workflow).toContain('const buffer = Buffer.alloc(130);'); + expect(workflow).toContain('const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0);'); + expect(workflow).not.toContain("fs.readFileSync(descriptor, 'utf8')"); + expect(workflow).toContain("if: ${{ always() && steps.install_apps.outputs.mutation_started == 'true' }}"); + expect(workflow).toContain('terminate_exact_app_processes "$BETA_APP"'); + expect(workflow).toContain('RC_FALLBACK_LAUNCH_VERIFIED=true'); + expect(workflow).not.toContain('pkill -f "EvaOSWorkbench|evaOS Workbench"'); + expect(workflow).not.toContain('pgrep -f "EvaOSWorkbench|evaOS Workbench"'); + expect(workflow).not.toContain('pgrep -f "${FALLBACK_APP_NAME%.app}"'); + expect(workflow).toContain('/bin/ps -ww -axo pid=,comm= > "$WORKBENCH_PROCESS_SNAPSHOT"'); + expect(workflow).not.toContain('connector-service start'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-pre-canary.stdout'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-pre-canary.stderr'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-connector.stdout'); + expect(workflow).not.toContain('$PROOF_DIR/installed-candidate-connector.stderr'); + + const processScriptMarker = 'node - "$WORKBENCH_PROCESS_SNAPSHOT" <<\'NODE\''; + const processScriptStart = workflow.indexOf(processScriptMarker); + const processBodyStart = workflow.indexOf('\n', processScriptStart) + 1; + const processBodyEnd = workflow.indexOf('\n NODE', processBodyStart); + const processScript = workflow + .slice(processBodyStart, processBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(processScriptStart).toBeGreaterThan(-1); + expect(processBodyEnd).toBeGreaterThan(processBodyStart); + + const processDir = mkdtempSync(join(tmpdir(), 'evaos-rc-process-audit-')); + try { + const snapshotPath = join(processDir, 'processes.txt'); + writeFileSync(snapshotPath, ' 101 /Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench\n'); + const canonical = spawnSync(process.execPath, ['-', snapshotPath], { + encoding: 'utf8', + input: processScript, + }); + expect(canonical.status).toBe(0); + + writeFileSync( + snapshotPath, + ' 102 /Applications/evaOS Workbench.app/Contents/Frameworks/Electron Helper.app/Contents/MacOS/Electron Helper\n' + ); + const helperOnly = spawnSync(process.execPath, ['-', snapshotPath], { + encoding: 'utf8', + input: processScript, + }); + expect(helperOnly.status).toBe(4); + + writeFileSync(snapshotPath, ''); + const empty = spawnSync(process.execPath, ['-', snapshotPath], { + encoding: 'utf8', + input: processScript, + }); + expect(empty.status).toBe(4); + + writeFileSync(snapshotPath, ' 202 /Volumes/LEXAR/Codex/evidence/stale.app/Contents/MacOS/evaOS Workbench\n'); + const stale = spawnSync(process.execPath, ['-', snapshotPath], { + encoding: 'utf8', + input: processScript, + }); + expect(stale.status).toBe(2); + expect(stale.stdout).toBe(''); + expect(stale.stderr).toBe(''); + + writeFileSync( + snapshotPath, + ` 203 /Volumes/${'very-long-segment/'.repeat(40)}stale.app/Contents/MacOS/evaOS Workbench\n` + ); + const longStale = spawnSync(process.execPath, ['-', snapshotPath], { + encoding: 'utf8', + input: processScript, + }); + expect(longStale.status).toBe(2); + } finally { + rmSync(processDir, { recursive: true, force: true }); + } + + const exactProcessScriptMarker = 'node - "$process_snapshot" "$app_path" "$pid_output" <<\'NODE\''; + const exactProcessScriptStart = workflow.indexOf(exactProcessScriptMarker); + const exactProcessBodyStart = workflow.indexOf('\n', exactProcessScriptStart) + 1; + const exactProcessBodyEnd = workflow.indexOf('\n NODE', exactProcessBodyStart); + const exactProcessScript = workflow + .slice(exactProcessBodyStart, exactProcessBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(exactProcessScriptStart).toBeGreaterThan(-1); + expect(exactProcessBodyEnd).toBeGreaterThan(exactProcessBodyStart); + + const exactProcessHelperStart = workflow.indexOf(' write_exact_app_process_pids() {'); + const exactProcessHelperEnd = workflow.indexOf( + '\n terminate_exact_app_processes() {', + exactProcessHelperStart + ); + const exactProcessHelper = workflow + .slice(exactProcessHelperStart, exactProcessHelperEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(exactProcessHelperStart).toBeGreaterThan(-1); + expect(exactProcessHelperEnd).toBeGreaterThan(exactProcessHelperStart); + + const exactProcessDir = mkdtempSync(join(tmpdir(), 'evaos-rc-exact-processes-')); + try { + const snapshotPath = join(exactProcessDir, 'processes.txt'); + const outputPath = join(exactProcessDir, 'pids.txt'); + writeFileSync( + snapshotPath, + [ + ' 301 /Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench', + ' 302 /Applications/evaOS Workbench.app/Contents/Frameworks/Electron Helper.app/Contents/MacOS/Electron Helper', + ' 303 /Applications/evaOS Workbench.app.old/Contents/MacOS/evaOS Workbench', + ' 304 /Applications/Other Workbench.app/Contents/MacOS/evaOS Workbench', + ' 305 /bin/bash /Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench', + ].join('\n') + '\n' + ); + const filtered = spawnSync( + process.execPath, + ['-', snapshotPath, '/Applications/evaOS Workbench.app', outputPath], + { encoding: 'utf8', input: exactProcessScript } + ); + expect(filtered.status).toBe(0); + expect(filtered.stdout).toBe(''); + expect(filtered.stderr).toBe(''); + expect(readFileSync(outputPath, 'utf8')).toBe('301\n302\n'); + + const parserFailure = spawnSync( + '/bin/bash', + [ + '-c', + `${exactProcessHelper}\nwrite_exact_app_process_pids "$1" "$2" "$3"`, + 'evaos-helper-probe', + '/Applications/evaOS Workbench.app', + join(exactProcessDir, 'failure.snapshot'), + join(exactProcessDir, 'missing-parent', 'pids.txt'), + ], + { encoding: 'utf8' } + ); + expect(parserFailure.status).not.toBe(0); + } finally { + rmSync(exactProcessDir, { recursive: true, force: true }); + } + + const rollbackStepStart = workflow.indexOf('- name: Roll back beta and verify fallback'); + const exactMainScriptMarker = 'node - "$process_snapshot" "$app_path" "$executable_name" "$pid_output" <<\'NODE\''; + const exactMainScriptStart = workflow.indexOf(exactMainScriptMarker, rollbackStepStart); + const exactMainBodyStart = workflow.indexOf('\n', exactMainScriptStart) + 1; + const exactMainBodyEnd = workflow.indexOf('\n NODE', exactMainBodyStart); + const exactMainScript = workflow + .slice(exactMainBodyStart, exactMainBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(exactMainScriptStart).toBeGreaterThan(rollbackStepStart); + expect(exactMainBodyEnd).toBeGreaterThan(exactMainBodyStart); + + const exactMainDir = mkdtempSync(join(tmpdir(), 'evaos-rc-exact-main-')); + try { + const snapshotPath = join(exactMainDir, 'processes.txt'); + const outputPath = join(exactMainDir, 'pids.txt'); + writeFileSync( + snapshotPath, + [ + ' 401 /Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench', + ' 402 /Applications/evaOS Workbench.app/Contents/Frameworks/Electron Helper.app/Contents/MacOS/Electron Helper', + ' 403 /Applications/Other Workbench.app/Contents/MacOS/evaOS Workbench', + ].join('\n') + '\n' + ); + const filtered = spawnSync( + process.execPath, + ['-', snapshotPath, '/Applications/evaOS Workbench.app', 'evaOS Workbench', outputPath], + { encoding: 'utf8', input: exactMainScript } + ); + expect(filtered.status).toBe(0); + expect(filtered.stdout).toBe(''); + expect(filtered.stderr).toBe(''); + expect(readFileSync(outputPath, 'utf8')).toBe('401\n'); + } finally { + rmSync(exactMainDir, { recursive: true, force: true }); + } + + const dwellStart = workflow.indexOf(' FALLBACK_LAUNCH_PID=""'); + const dwellMarker = ' echo "RC_FALLBACK_LAUNCH_VERIFIED=true" >> "$GITHUB_ENV"'; + const dwellEnd = workflow.indexOf('\n', workflow.indexOf(dwellMarker, dwellStart)) + 1; + const dwellScript = workflow + .slice(dwellStart, dwellEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n') + .replaceAll('sleep 1', ':'); + expect(dwellStart).toBeGreaterThan(rollbackStepStart); + expect(dwellEnd).toBeGreaterThan(dwellStart); + + const dwellDir = mkdtempSync(join(tmpdir(), 'evaos-rc-fallback-dwell-')); + try { + const pidPath = join(dwellDir, 'main.pids'); + const snapshotPath = join(dwellDir, 'main.snapshot'); + const githubEnvPath = join(dwellDir, 'github.env'); + const runDwell = (transient: boolean) => { + writeFileSync(pidPath, '501\n'); + writeFileSync(githubEnvPath, ''); + return spawnSync( + '/bin/bash', + [ + '-c', + `set -euo pipefail +write_exact_app_main_pids() { + if [ "$TRANSIENT_PROCESS" = "true" ]; then + : > "$4" + else + printf '501\\n' > "$4" + fi +} +${dwellScript}`, + ], + { + encoding: 'utf8', + env: { + ...process.env, + TRANSIENT_PROCESS: transient ? 'true' : 'false', + FALLBACK_MAIN_PIDS: pidPath, + FALLBACK_MAIN_SNAPSHOT: snapshotPath, + FALLBACK_APP: '/Applications/evaOS Workbench.app', + ACTUAL_FALLBACK_EXECUTABLE: 'evaOS Workbench', + GITHUB_ENV: githubEnvPath, + }, + } + ); + }; + + const stable = runDwell(false); + expect(stable.status).toBe(0); + expect(readFileSync(githubEnvPath, 'utf8')).toContain('RC_FALLBACK_LAUNCH_VERIFIED=true'); + + const transient = runDwell(true); + expect(transient.status).not.toBe(0); + expect(readFileSync(githubEnvPath, 'utf8')).not.toContain('RC_FALLBACK_LAUNCH_VERIFIED=true'); + } finally { + rmSync(dwellDir, { recursive: true, force: true }); + } + + const atomicFunctionStart = workflow.indexOf('read_connector_token_atomically() {'); + const atomicScriptMarker = 'node - "$TOKEN_FILE" <<\'NODE\''; + const atomicScriptStart = workflow.indexOf(atomicScriptMarker, atomicFunctionStart); + const atomicBodyStart = workflow.indexOf('\n', atomicScriptStart) + 1; + const atomicBodyEnd = workflow.indexOf('\n NODE', atomicBodyStart); + const atomicScript = workflow + .slice(atomicBodyStart, atomicBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(atomicFunctionStart).toBeGreaterThan(-1); + expect(atomicScriptStart).toBeGreaterThan(atomicFunctionStart); + expect(atomicBodyEnd).toBeGreaterThan(atomicBodyStart); + + const tokenDir = mkdtempSync(join(tmpdir(), 'evaos-rc-token-reader-')); + try { + const tokenPath = join(tokenDir, 'connector.token'); + const symlinkPath = join(tokenDir, 'connector-link.token'); + const token = 'a'.repeat(64); + writeFileSync(tokenPath, `${token}\n`); + chmodSync(tokenPath, 0o600); + const valid = spawnSync(process.execPath, ['-', tokenPath], { + encoding: 'utf8', + input: atomicScript, + }); + expect(valid.status).toBe(0); + expect(valid.stdout).toBe(token); + expect(valid.stderr).toBe(''); + + symlinkSync(tokenPath, symlinkPath); + const symlinked = spawnSync(process.execPath, ['-', symlinkPath], { + encoding: 'utf8', + input: atomicScript, + }); + expect(symlinked.status).toBe(2); + expect(symlinked.stdout).toBe(''); + + chmodSync(tokenPath, 0o644); + const broadMode = spawnSync(process.execPath, ['-', tokenPath], { + encoding: 'utf8', + input: atomicScript, + }); + expect(broadMode.status).toBe(2); + expect(broadMode.stdout).toBe(''); + + chmodSync(tokenPath, 0o600); + writeFileSync(tokenPath, 'b'.repeat(130)); + const oversized = spawnSync(process.execPath, ['-', tokenPath], { + encoding: 'utf8', + input: atomicScript, + }); + expect(oversized.status).toBe(2); + expect(oversized.stdout).toBe(''); + + const growthScript = atomicScript.replace( + "const fs = require('fs');", + `const realFs = require('fs'); +const fs = { + constants: realFs.constants, + openSync: () => 42, + fstatSync: () => ({ + isFile: () => true, + uid: process.getuid(), + mode: 0o100600, + size: 64, + dev: 1, + ino: 1, + mtimeMs: 1, + }), + readSync: (_descriptor, buffer, _offset, length) => { + if (length !== 130) process.exit(9); + buffer.fill(0x61); + return 130; + }, + closeSync: () => {}, +};` + ); + expect(growthScript).not.toBe(atomicScript); + const growing = spawnSync(process.execPath, ['-', tokenPath], { + encoding: 'utf8', + input: growthScript, + }); + expect(growing.status).toBe(2); + expect(growing.stdout).toBe(''); + expect(growing.stderr).toBe(''); + } finally { + rmSync(tokenDir, { recursive: true, force: true }); + } + + const failureScriptMarker = "node - rc-failure-proof/failure-summary.json <<'NODE'"; + const failureScriptStart = workflow.indexOf(failureScriptMarker); + const failureBodyStart = workflow.indexOf('\n', failureScriptStart) + 1; + const failureBodyEnd = workflow.indexOf('\n NODE', failureBodyStart); + const failureScript = workflow + .slice(failureBodyStart, failureBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + expect(failureScriptStart).toBeGreaterThan(-1); + expect(failureBodyEnd).toBeGreaterThan(failureBodyStart); + + const failureDir = mkdtempSync(join(tmpdir(), 'evaos-rc-failure-sanitizer-')); + try { + const outputPath = join(failureDir, 'failure-summary.json'); + const hostile = + 'Bearer fixture-secret https://secret.example 2001:db8::1 /Users/private/connector.token pid=4242'; + const result = spawnSync(process.execPath, ['-', outputPath], { + encoding: 'utf8', + input: failureScript, + env: { + ...process.env, + RC_PHASE: hostile, + RC_CONNECTOR_READINESS_CLASSIFICATION: hostile, + RC_CONNECTOR_START_INVOKED: 'true', + RC_CONNECTOR_PROCESS_RUNNING: 'false', + RC_CONNECTOR_PROCESS_EXIT_CODE: '2', + RC_CONNECTOR_ATTEMPTS: '4', + RC_CONNECTOR_TOKEN_EXISTS: 'true', + RC_CONNECTOR_TOKEN_REGULAR: 'true', + RC_CONNECTOR_TOKEN_OWNER_MATCH: 'true', + RC_CONNECTOR_TOKEN_MODE_600: 'true', + RC_CONNECTOR_TOKEN_NONEMPTY: 'true', + RC_CONNECTOR_HEALTH_REACHABLE: 'false', + RC_QA_STARTED: 'false', + RC_QA_SUCCEEDED: 'false', + RC_CONNECTOR_CLEANUP_ATTEMPTED: 'true', + RC_CONNECTOR_CLEANUP_SUCCEEDED: 'true', + RC_WORKBENCH_CLEANUP_ATTEMPTED: 'true', + RC_WORKBENCH_CLEANUP_SUCCEEDED: 'true', + RC_FALLBACK_LAUNCH_VERIFIED: 'true', + RC_ROLLBACK_ATTEMPTED: 'true', + RC_ROLLBACK_SUCCEEDED: 'true', + INSTALL_MUTATION_STARTED: 'true', + ROLLBACK_STEP_OUTCOME: 'success', + }, + }); + expect(result.status).toBe(0); + const summary = readFileSync(outputPath, 'utf8'); + expect(summary).toContain('"phase": "unknown"'); + expect(summary).toContain('"readiness": "unknown"'); + expect(summary).toContain('"workbenchCleanupSucceeded": true'); + expect(summary).toContain('"fallbackLaunchVerified": true'); + expect(summary).not.toMatch( + /fixture-secret|secret\.example|2001:db8::1|\/Users\/private|connector\.token|pid=4242/ + ); + } finally { + rmSync(failureDir, { recursive: true, force: true }); + } + }); + it('rejects dirty or untracked vendored bridge bytes in strict provenance checks', () => { expect(() => bridgeResource.assertVendoredBridgeSourceMatchesHead(