diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index a2c4df3f28..9081f5f647 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -114,6 +114,7 @@ jobs: run: | SHORT=$(git rev-parse --short HEAD) echo "short=$SHORT" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "Commit: $SHORT" - name: Setup Node.js @@ -782,8 +783,7 @@ jobs: GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN || github.token }} EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL: '1' - EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ vars.EVAOS_DESKTOP_BRIDGE_SOURCE_REF }} - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} + EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ steps.commit.outputs.sha }} - name: Validate macOS app staple inside DMG if: startsWith(matrix.platform, 'macos') diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index 9f23ca7626..9c283cd2ea 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: beta_rc_ack: - description: 'Type evaos-beta-rc to acknowledge this canary installs and launches release-candidate macOS apps.' + description: 'Type evaos-beta-rc to acknowledge this canary installs and launches the candidate, then locally starts Full Access and Ask Permission without performing desktop actions.' required: true type: string tag: @@ -299,6 +299,114 @@ jobs: NODE node scripts/evaosBetaReleaseGate.js write-rc-proof-template "$PROOF_DIR" "$TAG" + - name: Verify updater ZIP signature and notarization + env: + TAG: ${{ github.event.inputs.tag }} + TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} + run: | + set -euo pipefail + ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE' + const fs = require('fs'); + const metadataPath = process.argv[2]; + const text = fs.readFileSync(metadataPath, 'utf8'); + const refs = []; + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\s*(?:-\s*)?(?:path|url):\s*(.+?)\s*$/); + if (!match) continue; + let ref = match[1].trim().replace(/^['"]|['"]$/g, ''); + if (/^https?:\/\//i.test(ref)) ref = new URL(ref).pathname.split('/').pop(); + if (ref?.endsWith('.zip')) refs.push(ref); + } + const unique = [...new Set(refs)]; + if (unique.length !== 1 || !/arm64/i.test(unique[0])) { + throw new Error('latest-arm64-mac.yml must identify exactly one arm64 updater ZIP.'); + } + process.stdout.write(unique[0]); + NODE + ) + UPDATER_ZIP="release-assets/$ZIP_NAME" + if [ ! -f "$UPDATER_ZIP" ]; then + echo "::error::Updater metadata references missing arm64 ZIP: $ZIP_NAME" + exit 1 + fi + EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<'NODE' + const fs = require('fs'); + const [manifestPath, assetName] = process.argv.slice(2); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const asset = (manifest.assets || []).find((candidate) => candidate.name === assetName); + if (!asset || !/^[0-9a-f]{64}$/i.test(String(asset.sha256 || ''))) { + throw new Error(`Trusted manifest does not bind updater ZIP ${assetName}.`); + } + process.stdout.write(asset.sha256); + NODE + ) + ACTUAL_SHA=$(shasum -a 256 "$UPDATER_ZIP" | awk '{print $1}') + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "::error::Updater ZIP checksum does not match the trusted release manifest." + exit 1 + fi + + EXTRACT_DIR="$RUNNER_TEMP/evaos-updater-zip-arm64" + rm -rf "$EXTRACT_DIR" + mkdir -p "$EXTRACT_DIR" + ditto -x -k "$UPDATER_ZIP" "$EXTRACT_DIR" + ZIP_APP="$EXTRACT_DIR/$BETA_APP_NAME" + APP_ROOT_COUNT=$(find "$EXTRACT_DIR" -mindepth 1 -maxdepth 1 -type d -name '*.app' -print | wc -l | tr -d ' ') + if [ ! -d "$ZIP_APP" ] || [ "$APP_ROOT_COUNT" != "1" ]; then + echo "::error::Updater ZIP did not contain $BETA_APP_NAME." + exit 1 + fi + INFO_PLIST="$ZIP_APP/Contents/Info.plist" + if [ ! -f "$INFO_PLIST" ]; then + echo "::error::Updater ZIP is missing the canonical app Info.plist." + exit 1 + fi + /usr/libexec/PlistBuddy -c Print "$INFO_PLIST" >/dev/null + if [[ ! "$TAG" =~ ^evaos-beta-v?([0-9]+\.[0-9]+\.[0-9]+)-evaos-beta(\.[0-9]+)?$ ]]; then + echo "::error::Unable to derive the updater app version from tag $TAG." + exit 1 + fi + EXPECTED_VERSION="${BASH_REMATCH[1]}" + BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INFO_PLIST") + PRODUCT_NAME=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$INFO_PLIST") + SHORT_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST") + BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST") + if [ "$BUNDLE_ID" != "com.evaos.workbench" ] || [ "$PRODUCT_NAME" != "evaOS Workbench" ] || [ "$SHORT_VERSION" != "$EXPECTED_VERSION" ] || [ "$BUNDLE_VERSION" != "$EXPECTED_VERSION" ]; then + echo "::error::Updater ZIP app identity does not match the RC tag and canonical Workbench identity." + exit 1 + fi + + codesign --verify --deep --strict --verbose=2 "$ZIP_APP" > "$PROOF_DIR/codesign-updater-zip-macos-arm64.txt" 2>&1 + xcrun stapler validate "$ZIP_APP" > "$PROOF_DIR/stapler-updater-zip-macos-arm64.txt" 2>&1 + spctl --assess --type execute --verbose "$ZIP_APP" > "$PROOF_DIR/spctl-updater-zip-macos-arm64.txt" 2>&1 + node - "$PROOF_DIR/updater-zip-macos-arm64.json" "$TAG" "$TAG_COMMIT" "$ZIP_NAME" "$ACTUAL_SHA" "$BETA_APP_NAME" "$BUNDLE_ID" "$PRODUCT_NAME" "$SHORT_VERSION" "$BUNDLE_VERSION" <<'NODE' + const fs = require('fs'); + const [outputPath, tag, releaseCommit, assetName, sha256, appName, bundleId, productName, shortVersion, bundleVersion] = process.argv.slice(2); + fs.writeFileSync( + outputPath, + `${JSON.stringify( + { + schema: 'evaos-updater-zip-trust/v2', + tag, + releaseCommit, + assetName, + sha256, + appName, + bundleId, + productName, + shortVersion, + bundleVersion, + codesignVerified: true, + staplerVerified: true, + gatekeeperVerified: true, + }, + null, + 2 + )}\n` + ); + NODE + rm -rf "$EXTRACT_DIR" + - name: Install fallback and beta apps env: FALLBACK_APP_NAME: ${{ github.event.inputs.fallback_app_name }} @@ -340,6 +448,7 @@ jobs: fi rm -rf "/Applications/$app_name" ditto "$app_path" "/Applications/$app_name" + rm -rf "$extract_dir" } install_fallback_app() { @@ -427,6 +536,8 @@ jobs: spctl --assess --type execute --verbose "$BETA_APP" > "$PROOF_DIR/spctl-macos-arm64.txt" 2>&1 - name: Launch beta and audit feed isolation + env: + TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} run: | set -euo pipefail BETA_APP="/Applications/$BETA_APP_NAME" @@ -534,6 +645,80 @@ jobs: printf '%s\n' "$STALE_RUNNING_APPS" exit 1 fi + + BRIDGE_COMMAND="$BETA_APP/Contents/Resources/Bridge/evaos-desktop-bridge" + if [ ! -x "$BRIDGE_COMMAND" ]; then + 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" + rm -rf "$PRE_CANARY_DIR" "$CONNECTOR_CANARY_DIR" + "$BRIDGE_COMMAND" pre-canary \ + --json \ + --control-surface bridge-peekaboo \ + --expected-version "$SHORT_VERSION" \ + --expected-build "$BUNDLE_VERSION" \ + --expected-source-commit "$TAG_COMMIT" \ + --artifact-dir "$PRE_CANARY_DIR" \ + > "$PROOF_DIR/installed-candidate-pre-canary.stdout.json" \ + 2> "$PROOF_DIR/installed-candidate-pre-canary.stderr.txt" + cp "$PRE_CANARY_DIR/qa-report.json" "$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 + break + fi + sleep 1 + done + if [ ! -s "$TOKEN_FILE" ]; then + echo "::error::Installed Workbench did not create its connector token." + exit 1 + 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 + fi + CONNECTOR_TOKEN=$(tr -d '\r\n' < "$TOKEN_FILE") + if [ -z "$CONNECTOR_TOKEN" ]; then + echo "::error::Installed Workbench connector token is empty." + exit 1 + fi + set +e + 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" \ + --build-under-test "$BUNDLE_VERSION" \ + --source-commit-under-test "$TAG_COMMIT" \ + --surface connector \ + --suite control_start \ + --operator-ack-live-control \ + > "$PROOF_DIR/installed-candidate-connector.stdout.json" \ + 2> "$PROOF_DIR/installed-candidate-connector.stderr.txt" + QA_CANARY_EXIT=$? + set -e + if [ -f "$CONNECTOR_CANARY_DIR/qa-report.json" ]; then + cp "$CONNECTOR_CANARY_DIR/qa-report.json" "$PROOF_DIR/installed-candidate-connector.json" + fi + TOKEN_SCAN_EXIT=0 + LC_ALL=C grep -R -F -- "$CONNECTOR_TOKEN" "$PROOF_DIR" >/dev/null 2>&1 || TOKEN_SCAN_EXIT=$? + if [ "$TOKEN_SCAN_EXIT" -eq 0 ]; then + echo "::error::Installed candidate proof leaked the connector token." + unset CONNECTOR_TOKEN + exit 1 + fi + if [ "$TOKEN_SCAN_EXIT" -ne 1 ]; then + echo "::error::Installed candidate proof token scan could not complete safely." + unset CONNECTOR_TOKEN + exit 1 + fi + unset CONNECTOR_TOKEN + if [ "$QA_CANARY_EXIT" -ne 0 ]; then + echo "::error::Installed candidate connector canary failed; inspect the sanitized proof artifact." + exit 1 + fi pkill -f "EvaOSWorkbench|evaOS Workbench" || true { @@ -667,6 +852,7 @@ jobs: TAG: ${{ github.event.inputs.tag }} EXPECTED_RELEASE_COMMIT: ${{ steps.provenance.outputs.tag_commit }} EVAOS_BETA_LOCAL_SIGNED_DMG_FALLBACK_ACK: ${{ github.event.inputs.local_signed_dmg_fallback_ack }} + EVAOS_BETA_RC_RELEASE_ASSETS_DIR: release-assets run: | set -euo pipefail { diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6fc9b51c66..3cfc8f0071 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -492,7 +492,6 @@ jobs: CI: true GH_TOKEN: ${{ github.token }} EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER: '1' - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || github.token }} - name: Verify build artifacts exist shell: bash @@ -749,10 +748,10 @@ jobs: EVAOS_BETA_RELEASE_WORKFLOW: Build and Release GITHUB_RUN_ID: '12345' GITHUB_RUN_ATTEMPT: '1' - EVAOS_BETA_RELEASE_COMMIT: mock-sha + EVAOS_BETA_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_RELEASE_BRANCH: evaos/release-public-beta EVAOS_BETA_RELEASE_PUBLISH_ENABLED: 'true' - EXPECTED_RELEASE_COMMIT: mock-sha + EXPECTED_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1' run: | node scripts/evaosBetaReleaseGate.js write-manifest release-assets evaos-beta-v1.0.0-evaos-beta.0 @@ -772,11 +771,11 @@ jobs: EVAOS_BETA_RELEASE_WORKFLOW: Build and Release GITHUB_RUN_ID: '12345' GITHUB_RUN_ATTEMPT: '1' - EVAOS_BETA_RELEASE_COMMIT: mock-sha + EVAOS_BETA_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_RELEASE_BRANCH: evaos/release-public-beta EVAOS_BETA_RELEASE_PUBLISH_ENABLED: 'true' EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1' - EXPECTED_RELEASE_COMMIT: mock-sha + EXPECTED_RELEASE_COMMIT: ${{ github.sha }} INCLUDE_WEB_CLI_ASSETS: '0' EVAOS_RELEASE_TARGET_PLATFORMS: macos MOCK_VERSION: '1.0.0' @@ -807,11 +806,11 @@ jobs: EVAOS_BETA_RELEASE_WORKFLOW: Build and Release GITHUB_RUN_ID: '12345' GITHUB_RUN_ATTEMPT: '1' - EVAOS_BETA_RELEASE_COMMIT: mock-sha + EVAOS_BETA_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_RELEASE_BRANCH: evaos/release-public-beta EVAOS_BETA_RELEASE_PUBLISH_ENABLED: 'true' EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1' - EXPECTED_RELEASE_COMMIT: mock-sha + EXPECTED_RELEASE_COMMIT: ${{ github.sha }} INCLUDE_WEB_CLI_ASSETS: '0' EVAOS_RELEASE_TARGET_PLATFORMS: macos-arm64 MOCK_VERSION: '1.0.0' @@ -867,11 +866,11 @@ jobs: EVAOS_BETA_RELEASE_WORKFLOW: Build and Release GITHUB_RUN_ID: '12345' GITHUB_RUN_ATTEMPT: '1' - EVAOS_BETA_RELEASE_COMMIT: mock-sha + EVAOS_BETA_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_RELEASE_BRANCH: evaos/release-public-beta EVAOS_BETA_RELEASE_PUBLISH_ENABLED: 'true' EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1' - EXPECTED_RELEASE_COMMIT: mock-sha + EXPECTED_RELEASE_COMMIT: ${{ github.sha }} INCLUDE_WEB_CLI_ASSETS: '0' EVAOS_RELEASE_TARGET_PLATFORMS: windows MOCK_VERSION: '1.0.0' @@ -902,11 +901,11 @@ jobs: EVAOS_BETA_RELEASE_WORKFLOW: Build and Release GITHUB_RUN_ID: '12345' GITHUB_RUN_ATTEMPT: '1' - EVAOS_BETA_RELEASE_COMMIT: mock-sha + EVAOS_BETA_RELEASE_COMMIT: ${{ github.sha }} EVAOS_BETA_RELEASE_BRANCH: evaos/release-public-beta EVAOS_BETA_RELEASE_PUBLISH_ENABLED: 'true' EVAOS_BETA_SKIP_GITHUB_RUN_VERIFY: '1' - EXPECTED_RELEASE_COMMIT: mock-sha + EXPECTED_RELEASE_COMMIT: ${{ github.sha }} INCLUDE_WEB_CLI_ASSETS: '0' EVAOS_RELEASE_TARGET_PLATFORMS: macos EVAOS_MOCK_MACOS_DMG_ONLY: '1' diff --git a/.github/workflows/release-distribute.yml b/.github/workflows/release-distribute.yml index b256346648..1b3f0e1672 100644 --- a/.github/workflows/release-distribute.yml +++ b/.github/workflows/release-distribute.yml @@ -269,6 +269,7 @@ jobs: TAG: ${{ steps.version.outputs.tag }} EXPECTED_RELEASE_COMMIT: ${{ steps.provenance.outputs.tag_commit }} EVAOS_BETA_LOCAL_SIGNED_DMG_FALLBACK_ACK: ${{ github.event.inputs.local_signed_dmg_fallback_ack }} + EVAOS_BETA_RC_RELEASE_ASSETS_DIR: dist run: | set -euo pipefail if [ -z "$RC_PROOF_RUN_ID" ]; then @@ -276,9 +277,10 @@ jobs: exit 1 fi - RUN_JSON=$(gh run view "$RC_PROOF_RUN_ID" --repo "${{ github.repository }}" --json conclusion,event,workflowName) - node - "$RUN_JSON" <<'NODE' + RUN_JSON=$(gh run view "$RC_PROOF_RUN_ID" --repo "${{ github.repository }}" --json conclusion,event,workflowName,headSha,createdAt) + node - "$RUN_JSON" "$EXPECTED_RELEASE_COMMIT" <<'NODE' const run = JSON.parse(process.argv[2]); + const expectedHead = process.argv[3]; if (run.conclusion !== 'success') { throw new Error(`RC proof run did not succeed: ${run.conclusion}`); } @@ -288,6 +290,14 @@ jobs: if (run.event !== 'workflow_dispatch') { throw new Error(`RC proof run was not manually dispatched: ${run.event}`); } + if (run.headSha !== expectedHead) { + throw new Error(`RC proof head ${run.headSha} does not match release commit ${expectedHead}.`); + } + const createdAt = Date.parse(run.createdAt || ''); + const ageMs = Date.now() - createdAt; + if (!Number.isFinite(createdAt) || ageMs < -5 * 60 * 1000 || ageMs > 24 * 60 * 60 * 1000) { + throw new Error(`RC proof run is outside the 24-hour publication window: ${run.createdAt || 'missing'}.`); + } NODE rm -rf rc-proof-download rc-proof diff --git a/.github/workflows/workbench-functional-smoke.yml b/.github/workflows/workbench-functional-smoke.yml index ebe63955ed..69f6eef6a8 100644 --- a/.github/workflows/workbench-functional-smoke.yml +++ b/.github/workflows/workbench-functional-smoke.yml @@ -16,11 +16,6 @@ on: required: false default: true type: boolean - bridge_ref: - description: evaos-desktop-bridge ref to bundle; defaults to EVAOS_DESKTOP_BRIDGE_SOURCE_REF repo variable - required: false - default: '' - type: string concurrency: group: workbench-functional-smoke-${{ github.event.inputs.ref }} @@ -45,7 +40,6 @@ env: PYTHON_RUNTIME_RELEASE: '20260510' PYTHON_RUNTIME_ARM64_SHA256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17' PYTHON_RUNTIME_X64_SHA256: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894' - WORKBENCH_SMOKE_BRIDGE_REF: ${{ inputs.bridge_ref || vars.EVAOS_DESKTOP_BRIDGE_SOURCE_REF }} WORKBENCH_SMOKE_REF: ${{ inputs.ref }} jobs: @@ -70,14 +64,16 @@ jobs: echo "short=$SHORT" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Validate immutable bridge ref + - name: Bind vendored bridge to checked-out Workbench commit shell: bash run: | set -euo pipefail + WORKBENCH_SMOKE_BRIDGE_REF="${{ steps.commit.outputs.sha }}" if [[ ! "$WORKBENCH_SMOKE_BRIDGE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Workbench functional smoke requires a full immutable evaos-desktop-bridge commit SHA." + echo "::error::Workbench functional smoke requires an exact evaOS-GUI commit SHA." exit 1 fi + echo "WORKBENCH_SMOKE_BRIDGE_REF=$WORKBENCH_SMOKE_BRIDGE_REF" >> "$GITHUB_ENV" - name: Setup Node.js uses: actions/setup-node@v4 @@ -180,9 +176,7 @@ jobs: shell: bash env: AIONUI_MANAGED_RESOURCES_BUNDLE: no-acp - EVAOS_DESKTOP_BRIDGE_DISABLE_DEFAULT_CANDIDATES: '1' EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ env.WORKBENCH_SMOKE_BRIDGE_REF }} - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} run: | set -euo pipefail echo "NODE_OPTIONS=${NODE_OPTIONS:-}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 436f94a7b4..e6c39e08c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ - Removes repeated start actions after an unproven handoff or once Mac Access is already ready, and routes recovery through the existing status refresh. +### Workbench-Owned Mac Bridge + +- Moves the bundled Mac bridge source into evaOS-GUI so Workbench release and + functional-smoke builds no longer clone or require the deprecated bridge + repository. +- Routes current and legacy Workbench focus aliases only to + `/Applications/evaOS Workbench.app`, verifies the `evaOS Workbench` process, + and refuses the legacy `/Applications/evaOS.app` target. +- Uses one authenticated connector-readiness snapshot across diagnostics and + status, and gives the larger authenticated diagnostics response its own + bounded deadline so a healthy Mac is not misreported as unauthenticated. +- Binds the packaged bridge source digest and ownership provenance to the exact + evaOS-GUI release commit before signing. + ### Selected-Binding Mac-control Canary - Adds a separately acknowledged, staging-only live canary for the deployed diff --git a/docs/evaos/mac-pairing-functional-proof-runbook.md b/docs/evaos/mac-pairing-functional-proof-runbook.md index d81e5f8b80..b3c65a0abc 100644 --- a/docs/evaos/mac-pairing-functional-proof-runbook.md +++ b/docs/evaos/mac-pairing-functional-proof-runbook.md @@ -19,15 +19,22 @@ The required order is: ## Bridge Packaging Gate -Pull request build smoke may set `EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER=1` so CI can verify Electron packaging even when the private `evaos-desktop-bridge` source is not readable from a pull request runner. - -That placeholder is never release proof. Any public or signed release build must set or inherit `EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL=1` and must provide one of: - -- `EVAOS_DESKTOP_BRIDGE_SOURCE_DIR` pointing at a checkout with `src/evaos_desktop_bridge/cli.py` -- `EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN` with read access to `electricsheephq/evaos-desktop-bridge` -- `EVAOS_DESKTOP_BRIDGE_SOURCE_REPO` / `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` for an approved, reachable bridge source - -For CI release builds, `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` must be a pinned tag or commit SHA, not `main`, `master`, or `HEAD`. If those inputs are missing, mutable, or inaccessible, the release build must fail before a public artifact is created. +The Workbench bridge source is owned and vendored by `evaOS-GUI` at +`resources/evaos-beta/bridge`. Pull request build smoke may still set +`EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER=1` for explicit negative-path packaging +tests, but a normal checkout does not fetch bridge source from another repository. + +That placeholder is never release proof. Any public or signed release build must set or inherit `EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL=1` and must provide both: + +- the checked-out `resources/evaos-beta/bridge/src/evaos_desktop_bridge` package; +- `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` set to the exact 40-character `evaOS-GUI` + checkout commit. + +The packaged manifest must record that same GUI commit, the owned source path, +the vendored ownership metadata, and the matching deterministic source digest. +External source-directory, repository, and token overrides are not release inputs. +If the owned source, exact commit binding, identity, or digest is missing or +different, the release build must fail before a public artifact is created. ## Functional Acceptance diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7794e80620..c68645ba81 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -113,7 +113,7 @@ extraResources: to: bundled-aioncore - from: resources/hub to: hub - # evaOS Mac connector bridge, generated from evaos-desktop-bridge by scripts/prepareEvaosDesktopBridgeResource.js + # evaOS-GUI-owned Mac connector bridge, assembled by scripts/prepareEvaosDesktopBridgeResource.js - from: resources/Bridge-${arch} to: Bridge win: diff --git a/packages/desktop/src/common/adapter/ipcBridge.ts b/packages/desktop/src/common/adapter/ipcBridge.ts index 6ea36c0441..6109b0547b 100644 --- a/packages/desktop/src/common/adapter/ipcBridge.ts +++ b/packages/desktop/src/common/adapter/ipcBridge.ts @@ -113,6 +113,7 @@ import { } from './mappers/teamMapper'; const EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS = 15000; +const EVAOS_NATIVE_COMPANION_ACTION_TIMEOUT_MS = 10 * 60 * 1000; const EVAOS_RUNTIME_SURFACE_PROTOCOL = 'evaos-runtime-surface:'; type EvaosElectronBridgeAPI = { @@ -126,12 +127,16 @@ type EvaosRendererWindow = Window & { __evaosProviderCallbacks?: Map void>; }; -function buildEvaosProvider(name: string) { +function buildEvaosProvider( + name: string, + options: { timeoutMs?: number | ((request: Request) => number) } = {} +) { const platformProvider = bridge.buildProvider(name); return { provider: platformProvider.provider, invoke(request: Request): Promise { - const electronRequest = invokeEvaosElectronProvider(name, request); + const timeoutMs = typeof options.timeoutMs === 'function' ? options.timeoutMs(request) : options.timeoutMs; + const electronRequest = invokeEvaosElectronProvider(name, request, timeoutMs); if (electronRequest) { return electronRequest; } @@ -140,7 +145,11 @@ function buildEvaosProvider(name: string) { }; } -function invokeEvaosElectronProvider(name: string, request: Request): Promise | undefined { +function invokeEvaosElectronProvider( + name: string, + request: Request, + timeoutMs = EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS +): Promise | undefined { const rendererWindow = getEvaosRendererWindow(); const electronAPI = rendererWindow?.electronAPI; if (!rendererWindow || !electronAPI) { @@ -156,7 +165,7 @@ function invokeEvaosElectronProvider(name: string, request: R const timeout = rendererWindow.setTimeout(() => { callbacks.delete(callbackName); reject(new Error(`Timed out waiting for evaOS provider response: ${name}`)); - }, EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS); + }, timeoutMs); callbacks.set(callbackName, (data) => { rendererWindow.clearTimeout(timeout); @@ -1592,7 +1601,13 @@ export const evaosNativeCompanion = { IEvaosNativeCompanionRepairActionRequest >('evaos.native-companion.open-repair-action'), runAction: buildEvaosProvider, IEvaosNativeCompanionActionRequest>( - 'evaos.native-companion.run-action' + 'evaos.native-companion.run-action', + { + timeoutMs: (request) => + request.action === 'secure_network_enroll' + ? EVAOS_NATIVE_COMPANION_ACTION_TIMEOUT_MS + : EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS, + } ), }; diff --git a/packages/desktop/src/common/evaos/bridgeTypes.ts b/packages/desktop/src/common/evaos/bridgeTypes.ts index 59c8bd6dbd..b5547ce024 100644 --- a/packages/desktop/src/common/evaos/bridgeTypes.ts +++ b/packages/desktop/src/common/evaos/bridgeTypes.ts @@ -403,6 +403,21 @@ export interface IEvaosNativeCompanionConnectorGrant { auditId?: string; } +export type IEvaosPrivateNetworkEnrollmentDiagnosticCode = + | 'enrollment_setup_failed' + | 'enrollment_secret_cleanup_failed' + | 'enrollment_state_changed' + | 'tailscale_cli_failed'; + +export type IEvaosPrivateNetworkEnrollmentCancellationState = 'cancelled' | 'unconfirmed' | 'unconfirmed_not_found'; + +export interface IEvaosPrivateNetworkEnrollmentDiagnostic { + code: IEvaosPrivateNetworkEnrollmentDiagnosticCode; + exitCode?: string; + message?: string; + cancellationState?: IEvaosPrivateNetworkEnrollmentCancellationState; +} + export interface IEvaosNativeCompanionActionResult { action: IEvaosNativeCompanionAction; status: IEvaosNativeCompanionActionStatus; @@ -419,6 +434,7 @@ export interface IEvaosNativeCompanionActionResult { events?: IEvaosNativeCompanionAuditEvent[]; blockerReason?: IEvaosMacControlBlockerReason; bootstrapGrantId?: string; + enrollmentDiagnostic?: IEvaosPrivateNetworkEnrollmentDiagnostic; } export interface IEvaosWorkbenchDiagnosticPacketV1 { diff --git a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts index 45a18bbd0e..e3f9de5e82 100644 --- a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts +++ b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts @@ -57,39 +57,39 @@ export interface EvaosNativeCompanionCanary { } export const EVAOS_NATIVE_COMPANION_BOUNDARY_VERSION = '2026-06-06.rc-parity'; +export const EVAOS_PACKAGED_BRIDGE_COMMAND = + '"/Applications/evaOS Workbench.app/Contents/Resources/Bridge/evaos-desktop-bridge"'; +const EVAOS_CANARY_ARTIFACT_DIR_ARG = + '"${EVAOS_CANARY_ARTIFACT_DIR:?Set EVAOS_CANARY_ARTIFACT_DIR to an empty evidence directory}"'; +const EVAOS_CONNECTOR_URL_ARG = + '"${EVAOS_DESKTOP_BRIDGE_URL:?Set EVAOS_DESKTOP_BRIDGE_URL to the selected connector URL}"'; +const EVAOS_EXPECTED_WORKBENCH_VERSION_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_VERSION:?Set EVAOS_WORKBENCH_EXPECTED_VERSION to the exact candidate version}"'; +const EVAOS_EXPECTED_WORKBENCH_BUILD_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_BUILD:?Set EVAOS_WORKBENCH_EXPECTED_BUILD to the exact candidate build}"'; +const EVAOS_EXPECTED_SOURCE_COMMIT_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_SOURCE_COMMIT:?Set EVAOS_WORKBENCH_EXPECTED_SOURCE_COMMIT to the exact 40-character evaOS-GUI commit}"'; +const EVAOS_SELECTED_BINDING_PROOF_ARG = + '"${EVAOS_MAC_CONTROL_LIVE_CANARY_PROOF:?Set EVAOS_MAC_CONTROL_LIVE_CANARY_PROOF to the sanitized selected-binding callback proof}"'; +const EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG = + '"${EVAOS_MAC_CONTROL_LIVE_CANARY_RUN_ID:?Set EVAOS_MAC_CONTROL_LIVE_CANARY_RUN_ID to the exact proof workflow run id}"'; export const EVAOS_NATIVE_COMPANION_CANARIES = [ { id: 'pre-canary-bridge-peekaboo', - command: 'PYTHONPATH=src python3 -m evaos_desktop_bridge.pre_canary --json --control-surface bridge-peekaboo', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} pre-canary --json --control-surface bridge-peekaboo --expected-version ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --expected-build ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --expected-source-commit ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG}`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface connector --suite all --operator-ack-live-control', - requiredArtifact: 'qa-report.json', - forbidsSkips: true, - }, - { - id: 'openclaw-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface openclaw --suite all --operator-ack-live-control', - requiredArtifact: 'qa-report.json', - forbidsSkips: true, - }, - { - id: 'hermes-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface hermes --suite all --operator-ack-live-control', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --version-under-test ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --build-under-test ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --source-commit-under-test ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --selected-binding-proof ${EVAOS_SELECTED_BINDING_PROOF_ARG} --selected-binding-proof-run-id ${EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG} --surface connector --suite all --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-kill-switch', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface connector --suite kill_switch --operator-ack-live-control', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --version-under-test ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --build-under-test ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --source-commit-under-test ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --selected-binding-proof ${EVAOS_SELECTED_BINDING_PROOF_ARG} --selected-binding-proof-run-id ${EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG} --surface connector --suite kill_switch --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, diff --git a/packages/desktop/src/process/services/evaosBrokerSession.ts b/packages/desktop/src/process/services/evaosBrokerSession.ts index 073f33876c..344d2f91dd 100644 --- a/packages/desktop/src/process/services/evaosBrokerSession.ts +++ b/packages/desktop/src/process/services/evaosBrokerSession.ts @@ -87,6 +87,7 @@ export const EVAOS_CUSTOMER_MAC_CONTROL_ENDPOINT = 'https://rhfojelkgtwcxnrfhtlj.supabase.co/functions/v1/customer-mac-control'; const PROVIDER_CONNECTION_PROOF_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS = 20_000; type EvaosBusinessBrowserActionKind = 'runtime_launch' | 'browser_open_url' | 'browser_stop'; const RELEASED_WORKBENCH_KEYCHAIN_SERVICE = 'com.electricsheephq.EvaDesktop.session'; const RELEASED_WORKBENCH_KEYCHAIN_ACCOUNT = 'desktop-session'; @@ -135,12 +136,14 @@ export type EvaosBrokerErrorCode = export class EvaosBrokerSessionError extends Error { readonly code: EvaosBrokerErrorCode; readonly status?: number; + readonly brokerErrorCode?: string; - constructor(code: EvaosBrokerErrorCode, message: string, status?: number) { + constructor(code: EvaosBrokerErrorCode, message: string, status?: number, brokerErrorCode?: string) { super(message); this.name = 'EvaosBrokerSessionError'; this.code = code; this.status = status; + this.brokerErrorCode = safeBrokerResponseCode(brokerErrorCode); } } @@ -881,7 +884,8 @@ export class EvaosBrokerSessionClient { device_identifier: deviceIdentifier, client_variant: clientVariant, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ); } catch (error) { if (error instanceof EvaosBrokerSessionError && error.status === 401) { @@ -943,7 +947,8 @@ export class EvaosBrokerSessionClient { enrollment_id: enrollmentId, auth_key: authKey, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ); } catch (error) { if (error instanceof EvaosBrokerSessionError && error.status === 401) { @@ -1206,8 +1211,8 @@ export class EvaosBrokerSessionClient { if (response.status === 401 && session) { this.clearRejectedSession(session); } - const message = await brokerHttpMessageFromResponse(response); - throw new EvaosBrokerSessionError('broker_http_error', message, response.status); + const details = await brokerHttpDetailsFromResponse(response); + throw new EvaosBrokerSessionError('broker_http_error', details.message, response.status, details.brokerErrorCode); } try { @@ -1236,7 +1241,8 @@ export class EvaosBrokerSessionClient { enrollment_id: enrollmentId, auth_key: authKey, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ).catch((): void => undefined); } @@ -4884,22 +4890,30 @@ function stripUndefined(record: T): T { return record; } -async function brokerHttpMessageFromResponse(response: Response): Promise { +async function brokerHttpDetailsFromResponse( + response: Response +): Promise<{ message: string; brokerErrorCode?: string }> { const fallback = brokerHttpMessage(response.status); - if (!canSurfaceBrokerResponseMessage(response.status)) { - return fallback; - } - try { const record = asRecord(await response.clone().json()); - const message = safeText(record?.error ?? record?.message, 180); - return message ?? fallback; + const brokerErrorCode = safeBrokerResponseCode(record?.error_code ?? record?.code ?? record?.error); + const message = canSurfaceBrokerResponseMessage(response.status) + ? (safeText(record?.message ?? record?.error, 180) ?? fallback) + : fallback; + return { message, brokerErrorCode }; } catch { - return fallback; + return { message: fallback }; } } +function safeBrokerResponseCode(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const code = value.trim(); + if (!/^[a-z][a-z0-9_]{0,79}$/.test(code) || containsSecretMaterial(code)) return undefined; + return code; +} + function canSurfaceBrokerResponseMessage(status: number): boolean { return status === 400 || status === 409 || status === 422; } diff --git a/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts b/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts index 96c63f1a1f..2df1b8ce51 100644 --- a/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts +++ b/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts @@ -26,6 +26,8 @@ import type { IEvaosNativeCompanionControlMode, IEvaosMacControlBlockerReason, IEvaosNativeCompanionOpenResult, + IEvaosPrivateNetworkEnrollmentCancellationState, + IEvaosPrivateNetworkEnrollmentDiagnostic, IEvaosNativeCompanionPermissionView, IEvaosPrivateNetworkAuthorityDiagnostic, IEvaosNativeCompanionReadiness, @@ -65,7 +67,7 @@ const SECURE_NETWORK_APP_IDENTIFIERS: Record { + const beforeStart = await runBridgeCommand(bridgePath, ['customer-mac', 'control', 'status', '--json'], deps); + const generation = readNumber(beforeStart.data?.session, 'generation'); + if (!beforeStart.ok || generation === undefined || !Number.isSafeInteger(generation) || generation < 0) { + const detail = bridgeFailureDetail( + beforeStart, + 'Workbench could not bind control start to the current local safety state.' + ); + return nativeActionResult(request.action, 'repair_required', `Agent control could not start. ${detail}`, { + sourcePointer: 'native-companion:customer-mac-control-generation-required', + auditId: beforeStart.auditId, + auditIds: compactStrings([beforeStart.auditId]), + control: controlSummaryFromPayload(beforeStart.data), + blockerReason: classifyBridgeBlocker(beforeStart, 'connector_service_not_ready'), + }); + } const started = await runBridgeCommand( bridgePath, - ['customer-mac', 'control', 'start', '--json', '--mode', mode, '--agent-label', safeAgentLabel(request.agentLabel)], + [ + 'customer-mac', + 'control', + 'start', + '--json', + '--mode', + mode, + '--agent-label', + safeAgentLabel(request.agentLabel), + '--local-workbench-restart', + '--expected-control-generation', + String(generation), + ], deps ); if (started.ok) { @@ -2145,7 +2188,24 @@ export async function runNativeCompanionAction( case 'create_pairing_prompt': return createPairingPromptAction(request, bridgePath, deps); case 'secure_network_enroll': - return runSecureNetworkEnrollmentAction(request, bridgePath, deps); + if (secureNetworkEnrollmentInFlight) { + return nativeActionResult( + request.action, + 'repair_required', + 'Private-network enrollment is already in progress.', + { + sourcePointer: 'native-companion:secure-network-enrollment-already-in-progress', + refreshRecommended: false, + blockerReason: 'secure_network_link_required', + } + ); + } + secureNetworkEnrollmentInFlight = true; + try { + return await runSecureNetworkEnrollmentAction(request, bridgePath, deps); + } finally { + secureNetworkEnrollmentInFlight = false; + } default: return nativeActionResult(request.action, 'unsupported', 'Workbench connector action is not supported.', { sourcePointer: 'native-companion:unsupported-action', @@ -2276,8 +2336,10 @@ async function runSecureNetworkEnrollmentAction( bridgePath: string, deps: EvaosNativeCompanionStatusDeps ): Promise { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_action_started'); const customerId = request.customerId?.trim(); if (!customerId || isAccountLikeCustomerId(customerId)) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2302,6 +2364,7 @@ async function runSecureNetworkEnrollmentAction( localNetwork.enrolled !== false || !deviceIdentifier ) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2316,6 +2379,7 @@ async function runSecureNetworkEnrollmentAction( const client = await verifiedSecureNetworkClient(deps); if (!client) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2335,6 +2399,7 @@ async function runSecureNetworkEnrollmentAction( deps.cancelPrivateNetworkEnrollment ?? ((input) => getDefaultEvaosBrokerSessionClient().cancelPrivateNetworkEnrollment(input)); let enrollment: EvaosPrivateNetworkEnrollment; + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_broker_request_started'); try { enrollment = await createEnrollment({ customerId, @@ -2343,6 +2408,7 @@ async function runSecureNetworkEnrollmentAction( clientVariant: client.clientVariant, }); } catch (error) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_broker_request_failed'); if (isBrokerSessionReconnectRequired(error)) { return nativeActionResult( 'secure_network_enroll', @@ -2374,29 +2440,24 @@ async function runSecureNetworkEnrollmentAction( latestNetwork.clientRunning !== true || latestNetwork.enrolled !== false ) { - let cancelled = false; - try { - const cancellation = await cancelEnrollment({ - customerId, - enrollmentId: enrollment.enrollmentId, - authKey: enrollment.authKey, - }); - cancelled = cancellation.cancelled; - } catch { - cancelled = false; - } + const cancellation = await attemptPrivateNetworkEnrollmentCancellation(cancelEnrollment, customerId, enrollment); + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', - cancelled + cancellation.cancelled ? 'Private-network state changed before enrollment. Workbench cancelled the unused key safely; refresh before retrying.' : 'Private-network state changed before enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', { - sourcePointer: cancelled + sourcePointer: cancellation.cancelled ? 'native-companion:secure-network-enrollment-state-changed' : 'native-companion:secure-network-enrollment-cancel-unconfirmed', refreshRecommended: true, blockerReason: 'secure_network_link_required', + enrollmentDiagnostic: { + code: 'enrollment_state_changed', + cancellationState: cancellation.state, + }, } ); } @@ -2407,6 +2468,7 @@ async function runSecureNetworkEnrollmentAction( let secretReady = false; let cleanupFailed = false; let localEnrollmentSucceeded = false; + let enrollmentDiagnostic: IEvaosPrivateNetworkEnrollmentDiagnostic | undefined; try { secretDirectory = fs.mkdtempSync(join(tmpdir(), 'evaos-private-network-')); secretPath = join(secretDirectory, 'auth-key'); @@ -2417,25 +2479,42 @@ async function runSecureNetworkEnrollmentAction( mode: 0o600, }); secretReady = true; - } catch { + } catch (error) { recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_setup_failed'); localEnrollmentSucceeded = false; + enrollmentDiagnostic = { + code: 'enrollment_setup_failed', + message: secureNetworkEnrollmentErrorText(error, enrollment), + }; } if (secretReady && secretPath) { try { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_cli_started'); await execFile( client.commandPath, - ['login', `--login-server=${enrollment.loginServer}`, `--auth-key=file:${secretPath}`, '--timeout=20s'], + [ + 'up', + '--reset', + `--login-server=${enrollment.loginServer}`, + `--auth-key=file:${secretPath}`, + '--accept-dns=false', + '--timeout=90s', + ], { timeout: SECURE_NETWORK_ENROLL_TIMEOUT_MS, env: secureNetworkCliEnvironment(deps.env ?? process.env), } ); localEnrollmentSucceeded = true; - } catch { + } catch (error) { recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_login_failed'); localEnrollmentSucceeded = false; + enrollmentDiagnostic = { + code: 'tailscale_cli_failed', + exitCode: secureNetworkEnrollmentExitCode(error), + message: secureNetworkEnrollmentErrorText(error, enrollment, secretPath), + }; } } @@ -2447,6 +2526,7 @@ async function runSecureNetworkEnrollmentAction( recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_secret_unlink_failed'); localEnrollmentSucceeded = false; cleanupFailed = true; + enrollmentDiagnostic = { code: 'enrollment_secret_cleanup_failed' }; } } if (secretDirectory) { @@ -2456,6 +2536,7 @@ async function runSecureNetworkEnrollmentAction( recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_secret_directory_cleanup_failed'); localEnrollmentSucceeded = false; cleanupFailed = true; + enrollmentDiagnostic = { code: 'enrollment_secret_cleanup_failed' }; } } @@ -2464,34 +2545,35 @@ async function runSecureNetworkEnrollmentAction( } if (!localEnrollmentSucceeded) { - let cancelled = false; - try { - const cancellation = await cancelEnrollment({ - customerId, - enrollmentId: enrollment.enrollmentId, - authKey: enrollment.authKey, - }); - cancelled = cancellation.cancelled; - } catch { - cancelled = false; + const cancellation = await attemptPrivateNetworkEnrollmentCancellation(cancelEnrollment, customerId, enrollment); + if (!cancellation.cancelled && !cleanupFailed) { + localEnrollmentSucceeded = await waitForSecureNetworkEnrollmentAfterAmbiguousFailure(bridgePath, deps); + } + if (!localEnrollmentSucceeded) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_failed'); + return nativeActionResult( + 'secure_network_enroll', + 'repair_required', + cancellation.cancelled + ? 'Tailscale could not use the one-use enrollment. Workbench cancelled it safely; reopen Tailscale and retry.' + : 'Tailscale could not use the one-use enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', + { + sourcePointer: cancellation.cancelled + ? 'native-companion:secure-network-enrollment-client-failed' + : 'native-companion:secure-network-enrollment-cancel-unconfirmed', + refreshRecommended: false, + blockerReason: 'secure_network_link_required', + enrollmentDiagnostic: { + ...(enrollmentDiagnostic ?? { code: 'tailscale_cli_failed' }), + cancellationState: cancellation.state, + }, + } + ); } - return nativeActionResult( - 'secure_network_enroll', - 'repair_required', - cancelled - ? 'Tailscale could not use the one-use enrollment. Workbench cancelled it safely; reopen Tailscale and retry.' - : 'Tailscale could not use the one-use enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', - { - sourcePointer: cancelled - ? 'native-companion:secure-network-enrollment-client-failed' - : 'native-companion:secure-network-enrollment-cancel-unconfirmed', - refreshRecommended: false, - blockerReason: 'secure_network_link_required', - } - ); } privateNetworkBootstrapGrants.set(privateNetworkBootstrapGrantKey(customerId, deviceIdentifier), enrollment.grantId); + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_submitted'); return nativeActionResult( 'secure_network_enroll', @@ -2517,6 +2599,61 @@ function recordNativeCompanionDiagnosticEvent( console.warn(`[evaOS Native Companion] ${eventCode}`); } +async function attemptPrivateNetworkEnrollmentCancellation( + cancelEnrollment: (request: { + customerId: string; + enrollmentId: string; + authKey: string; + }) => Promise<{ cancelled: true; enrollmentId: string }>, + customerId: string, + enrollment: EvaosPrivateNetworkEnrollment +): Promise { + try { + const cancellation = await cancelEnrollment({ + customerId, + enrollmentId: enrollment.enrollmentId, + authKey: enrollment.authKey, + }); + if (cancellation.cancelled === true) return { cancelled: true, state: 'cancelled' }; + } catch (error) { + if (isEvaosBrokerSessionError(error) && error.brokerErrorCode === 'headscale_preauth_expiry_unconfirmed') { + return { cancelled: false, state: 'unconfirmed_not_found' }; + } + } + return { cancelled: false, state: 'unconfirmed' }; +} + +function secureNetworkEnrollmentExitCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') return undefined; + const record = error as { code?: unknown; exitCode?: unknown }; + const value = record.exitCode ?? record.code; + if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value); + if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(value)) return value; + return undefined; +} + +function secureNetworkEnrollmentErrorText( + error: unknown, + enrollment: EvaosPrivateNetworkEnrollment, + secretPath?: string +): string | undefined { + const stderr = readErrorStderr(error); + const message = error instanceof Error ? error.message : undefined; + let value = compactStrings([stderr, message]).join(' '); + if (!value) return undefined; + for (const sensitiveValue of [enrollment.authKey, enrollment.loginServer, secretPath] + .filter((candidate): candidate is string => Boolean(candidate)) + .toSorted((left, right) => right.length - left.length)) { + value = value.split(sensitiveValue).join('[redacted]'); + } + value = value + .replace(/--auth-key(?:=|\s+)\S+/gi, '[redacted]') + .replace(/\bfile:[^\s"')]+/gi, '[redacted-path]') + .replace(/(?:^|\s)(?:file:)?\/(?:Users|private|var|tmp)\/[^\s"'(),;]+/gi, ' [redacted-path]') + .replace(/\b(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9]{0,4}\b/g, '[redacted-ip]'); + return safeBridgeErrorText(value); +} + async function waitForSecureNetworkEnrollmentAfterAmbiguousFailure( bridgePath: string, deps: EvaosNativeCompanionStatusDeps @@ -2524,7 +2661,12 @@ async function waitForSecureNetworkEnrollmentAfterAmbiguousFailure( const sleep = deps.sleep ?? defaultSleep; for (let attempt = 0; attempt < SECURE_NETWORK_ENROLL_SETTLE_ATTEMPTS; attempt += 1) { if (attempt > 0) await sleep(SECURE_NETWORK_ENROLL_SETTLE_DELAY_MS); - const connectorService = await runBridgeCommand(bridgePath, ['connector-service', 'status', '--json'], deps); + const connectorService = await runBridgeCommand( + bridgePath, + ['connector-service', 'status', '--json'], + deps, + SECURE_NETWORK_ENROLL_SETTLE_COMMAND_TIMEOUT_MS + ); if (privateNetworkEvidence(connectorService.data)?.enrolled === true) return true; } return false; @@ -3357,9 +3499,9 @@ function bridgeFailureDetail(result: BridgeCommandResult, fallback: string): str function safeBridgeErrorText(value: string | undefined): string | undefined { if (!value) return undefined; const secretFieldPattern = - /["']?\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|token|secret)\b["']?\s*[:=]\s*["']?[^"'\s,)}]+["']?/gi; + /["']?\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|auth[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|token|secret)\b["']?\s*[:=]\s*["']?[^"'\s,)}]+["']?/gi; const secretWordPattern = - /\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|bearer|secret)\b[^\s,.;)]*/gi; + /\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|auth[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|bearer|secret)\b[^\s,.;)]*/gi; const redacted = value .replace(secretFieldPattern, '[redacted]') .replace(secretWordPattern, '[redacted]') @@ -3371,6 +3513,17 @@ function safeBridgeErrorText(value: string | undefined): string | undefined { return redacted ? redacted.slice(0, 260) : undefined; } +function safePrivateNetworkEnrollmentDiagnosticText(value: string | undefined): string | undefined { + if (!value) return undefined; + return safeBridgeErrorText( + value + .replace(/--auth-key(?:=|\s+)\S+/gi, '[redacted]') + .replace(/\bfile:[^\s"')]+/gi, '[redacted-path]') + .replace(/(?:^|\s)(?:file:)?\/(?:Users|private|var|tmp)\/[^\s"'(),;]+/gi, ' [redacted-path]') + .replace(/\b(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9]{0,4}\b/g, '[redacted-ip]') + ); +} + function isAccountLikeCustomerId(customerId: string): boolean { return customerId.includes('@'); } @@ -3509,6 +3662,35 @@ function nativeActionResult( events: options.events, blockerReason: rendererSafeMacControlBlockerReason(options.blockerReason), bootstrapGrantId: safeDiagnosticText(options.bootstrapGrantId), + enrollmentDiagnostic: rendererSafePrivateNetworkEnrollmentDiagnostic(options.enrollmentDiagnostic), + }; +} + +function rendererSafePrivateNetworkEnrollmentDiagnostic( + value: IEvaosPrivateNetworkEnrollmentDiagnostic | undefined +): IEvaosPrivateNetworkEnrollmentDiagnostic | undefined { + if (!value) return undefined; + const safeCodes = new Set([ + 'enrollment_setup_failed', + 'enrollment_secret_cleanup_failed', + 'enrollment_state_changed', + 'tailscale_cli_failed', + ]); + const safeCancellationStates = new Set([ + 'cancelled', + 'unconfirmed', + 'unconfirmed_not_found', + ]); + if (!safeCodes.has(value.code)) return undefined; + return { + code: value.code, + exitCode: + typeof value.exitCode === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(value.exitCode) ? value.exitCode : undefined, + message: safePrivateNetworkEnrollmentDiagnosticText(value.message), + cancellationState: + value.cancellationState && safeCancellationStates.has(value.cancellationState) + ? value.cancellationState + : undefined, }; } diff --git a/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts b/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts index 16218ffaf8..f56750980c 100644 --- a/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts +++ b/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts @@ -6,10 +6,12 @@ import type { IEvaosAccountPolicyScope, IEvaosRuntimeKey } from '@/common/evaos/bridgeTypes'; +// Historical Swift Workbench parity evidence only. The current packaged Mac +// bridge is owned by evaOS-GUI under resources/evaos-beta/bridge. export const GOLDEN_WORKBENCH_RELEASE_BASELINE = { releaseTag: 'evaos-workbench-v0.6.27', releaseUrl: 'https://github.com/electricsheephq/evaos-desktop-bridge/releases/tag/evaos-workbench-v0.6.27', - sourceCheckout: '/Volumes/LEXAR/repos/evaos-desktop-bridge', + archivedSource: 'electricsheephq/evaos-desktop-bridge@evaos-workbench-v0.6.27', } as const; export type GoldenWorkbenchSidebarSection = diff --git a/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts b/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts index ac34310afe..a9333d4c3a 100644 --- a/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts +++ b/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts @@ -181,7 +181,22 @@ export function useEvaosNativeCompanionStatus(enabled = true, customerId?: strin const runAction = useCallback( async (request: IEvaosNativeCompanionActionRequest): Promise => { - const response = await ipcBridge.evaosNativeCompanion.runAction.invoke(request); + let response: Awaited>; + try { + response = await ipcBridge.evaosNativeCompanion.runAction.invoke(request); + } catch { + return { + action: request.action, + status: 'failed', + message: 'Workbench connector action could not be reached.', + sourcePointer: + request.action === 'secure_network_enroll' + ? 'native-companion:secure-network-enrollment-action-unreachable' + : 'native-companion:action-unreachable', + auditIds: [], + refreshRecommended: true, + }; + } if (!response.success || !response.data) { return { action: request.action, diff --git a/packages/desktop/src/renderer/pages/native-companion/index.tsx b/packages/desktop/src/renderer/pages/native-companion/index.tsx index 6ed71bbd8c..f71562572e 100644 --- a/packages/desktop/src/renderer/pages/native-companion/index.tsx +++ b/packages/desktop/src/renderer/pages/native-companion/index.tsx @@ -62,6 +62,11 @@ const MAC_TARGET_BOUND_NATIVE_COMPANION_ACTIONS: ReadonlySet = new Set([ + 'control_stop', + 'kill_switch', + 'connector_stop', +]); const NativeCompanionPage: React.FC = () => { const { t } = useTranslation(); @@ -92,7 +97,14 @@ const NativeCompanionPage: React.FC = () => { const [connectorActionsOpen, setConnectorActionsOpen] = React.useState(false); const [handoffMessage, setHandoffMessage] = React.useState(null); const [actionResult, setActionResult] = React.useState(null); - const [actionInFlight, setActionInFlight] = React.useState(null); + const [actionsInFlight, setActionsInFlight] = React.useState< + ReadonlySet + >(() => new Set()); + const activeActionsRef = React.useRef(new Set()); + const nextOperationIdRef = React.useRef(0); + const latestPresentationIdRef = React.useRef(0); + const takeoverCueOwnerRef = React.useRef(null); + const anyActionInFlight = actionsInFlight.size > 0; const [authInFlight, setAuthInFlight] = React.useState(false); const [copyMessage, setCopyMessage] = React.useState(null); const [authUrl, setAuthUrl] = React.useState(null); @@ -260,9 +272,21 @@ const NativeCompanionPage: React.FC = () => { const handleRunAction = React.useCallback( async (request: IEvaosNativeCompanionActionRequest) => { - setActionInFlight(request.action); + const safetyAction = NATIVE_COMPANION_SAFETY_ACTIONS.has(request.action); + if (activeActionsRef.current.has(request.action) || (activeActionsRef.current.size > 0 && !safetyAction)) { + return; + } + const operationId = ++nextOperationIdRef.current; + latestPresentationIdRef.current = operationId; + activeActionsRef.current.add(request.action); + setActionsInFlight(new Set(activeActionsRef.current)); setCopyMessage(null); + takeoverCueOwnerRef.current = null; + setTakeoverCue(null); setTakeoverCueWarning(null); + if (request.action === 'secure_network_enroll') { + setHandoffMessage(t('evaos.nativeCompanion.onboarding.enrollmentConnectingDetail')); + } const targetsMacControlCustomer = MAC_TARGET_BOUND_NATIVE_COMPANION_ACTIONS.has(request.action); const requestCustomerId = request.customerId ?? (targetsMacControlCustomer ? selectedPairingCustomerId : selectedCustomerId); @@ -271,8 +295,13 @@ const NativeCompanionPage: React.FC = () => { } try { if (request.action === 'control_start') { - const cueResult = await runMacControlTakeoverCue(setTakeoverCue); - if (cueResult.warning) { + takeoverCueOwnerRef.current = operationId; + const cueResult = await runMacControlTakeoverCue((message) => { + if (takeoverCueOwnerRef.current === operationId && latestPresentationIdRef.current === operationId) { + setTakeoverCue(message); + } + }); + if (cueResult.warning && latestPresentationIdRef.current === operationId) { setTakeoverCueWarning(cueResult.warning); } } @@ -281,6 +310,9 @@ const NativeCompanionPage: React.FC = () => { customerId: requestCustomerId, agentLabel: request.agentLabel ?? 'evaOS Workbench', }); + if (operationId !== latestPresentationIdRef.current) { + return; + } if (targetsMacControlCustomer && requestCustomerId !== selectedPairingCustomerRef.current) { return; } @@ -297,9 +329,34 @@ const NativeCompanionPage: React.FC = () => { if (result.refreshRecommended) { await refresh(); } + } catch { + if (operationId !== latestPresentationIdRef.current) { + return; + } + if (targetsMacControlCustomer && requestCustomerId !== selectedPairingCustomerRef.current) { + return; + } + const failedResult: IEvaosNativeCompanionActionResult = { + action: request.action, + status: 'failed', + message: 'Workbench connector action could not be reached.', + sourcePointer: + request.action === 'secure_network_enroll' + ? 'native-companion:secure-network-enrollment-action-unreachable' + : 'native-companion:action-unreachable', + auditIds: [], + refreshRecommended: false, + }; + setActionResult(failedResult); + setActionResultCustomerId(targetsMacControlCustomer ? requestCustomerId : undefined); + setHandoffMessage(localizedNativeCompanionActionResultMessage(failedResult, t)); } finally { - setTakeoverCue(null); - setActionInFlight(null); + if (takeoverCueOwnerRef.current === operationId) { + takeoverCueOwnerRef.current = null; + setTakeoverCue(null); + } + activeActionsRef.current.delete(request.action); + setActionsInFlight(new Set(activeActionsRef.current)); } }, [refresh, runAction, selectedCustomerId, selectedPairingCustomerId, t] @@ -556,6 +613,7 @@ const NativeCompanionPage: React.FC = () => { targets={pairableMacControlTargets} selectedCustomerId={selectedPairingCustomerId} selectedTarget={selectedPairingTarget} + disabled={anyActionInFlight} onChange={handlePairingTargetChange} /> @@ -590,12 +648,12 @@ const NativeCompanionPage: React.FC = () => {