From ffdd07c527e7a3b0dd9665183c53e46a888f2030 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 16:54:42 +0700 Subject: [PATCH 1/6] docs(workbench): design pristine Mac onboarding --- ...2-workbench-fresh-mac-zero-touch-design.md | 222 ++++++++++++++++++ docs/superpowers/specs/README.md | 5 + 2 files changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md create mode 100644 docs/superpowers/specs/README.md diff --git a/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md b/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md new file mode 100644 index 0000000000..7ce0773d38 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md @@ -0,0 +1,222 @@ +# Workbench Fresh-Mac Zero-Touch Design + +Status: proposed for written-spec review + +Date: 2026-07-12 + +GitHub: [#662](https://github.com/100yenadmin/evaOS-GUI/issues/662), bridge producer [evaos-desktop-bridge#310](https://github.com/electricsheephq/evaos-desktop-bridge/issues/310), companion [#655](https://github.com/100yenadmin/evaOS-GUI/issues/655), parent [#623](https://github.com/100yenadmin/evaOS-GUI/issues/623), contract tracker [#480](https://github.com/100yenadmin/evaOS-GUI/issues/480) + +## Outcome + +A customer installing evaOS Workbench on a pristine supported Mac must not need Homebrew, host Python, pip, terminal commands, or prior private-network state. Workbench must provide one guided setup flow and must not report end-to-end readiness until the exact active customer, device, and grant pass the canonical broker/runtime Mac-control tools. + +The design preserves the existing broker, Headscale ACL, connector, TCC, stop/revoke, kill-switch, diagnostics, app identity, updater, no-ACP, and redaction contracts. CUA remains the preferred action engine when available; its absence does not affect pairing and must use the bundled Peekaboo fallback. + +## Decision + +### Selected approach: frozen self-contained bridge payload + +The Desktop Bridge producer will build a pinned macOS arm64 **one-directory frozen payload**. Its root `evaos-desktop-bridge` entry point is an arm64 Mach-O executable; its private runtime, Python modules, GUI fallback dependencies, libraries, and licenses live beside it under the payload directory. Workbench consumes that payload as an opaque, immutable resource at the existing `Contents/Resources/Bridge/` path. + +The producer build must pin the complete CPython, freezing-tool, bridge-source, PyObjC, GUI fallback, and transitive dependency set in a checked-in lock/manifest. It must use a one-directory form, not a one-file extractor, so code signing, notarization, runtime paths, and TCC identity remain inspectable inside the app bundle. The first producer PR chooses and freezes the exact toolchain through its own current-head review; later Workbench code must rely only on the payload contract, not on a specific freezer implementation. + +### Alternatives considered + +1. **Pinned relocatable Python plus shell launcher:** viable fallback if the frozen payload cannot be produced, but it leaves more interpreter/dylib relocation, signing, license, and manifest surface inside Workbench. It must never search host paths. +2. **Guided installation of host Python/Homebrew/pip:** rejected. It improves instructions but does not make Workbench self-contained and repeats David's failure mode. +3. **Immediate Swift/native rewrite of the entire bridge:** rejected for the P0 because it broadens scope and delays customer recovery. A later native rewrite may replace the frozen payload without changing the Workbench contract. + +## Architecture + +### 1. Desktop Bridge artifact contract + +The bridge repository owns production of a versioned payload containing: + +- `evaos-desktop-bridge`: executable arm64 Mach-O root entry point; +- private runtime/library directories required by every supported bridge command; +- bundled GUI fallback dependencies currently represented by the bridge's `gui` dependency set; +- licenses and an artifact manifest; +- source commit, immutable build-tool/dependency lock identity, target OS/architecture, root executable digest, payload digest, and bridge version. + +Required producer smoke runs in a scrubbed environment with no Homebrew or system-Python dependency and exercises `--version`, JSON status, connector status/startup preflight, and a non-destructive fallback-capability probe. It must not exercise customer control or expose connector material. + +### 2. Workbench packaging consumer + +`scripts/prepareEvaosDesktopBridgeResource.js` stops generating the release-mode host-Python wrapper. It accepts only a pinned payload directory/archive plus expected immutable identity, copies the whole payload to `resources/Bridge`, and records the producer identity in the existing Bridge manifest. + +Strict release preparation and post-pack validation must fail when: + +- the root bridge is a script or is not Mach-O; +- architecture does not match the target; +- requested source/artifact identity or digest is missing/mismatched; +- required payload files or licenses are absent; +- the root bridge or a nested executable/library escapes signing verification; +- the payload contains a host-Python/Homebrew fallback path; +- a placeholder or mutable source ref is used. + +`electron-builder` continues copying `resources/Bridge` as `extraResources`; the bridge does not move into AionCore managed resources and does not change the no-ACP contract. `afterSign` verifies the root bridge and every nested Mach-O against the expected Developer ID Team ID before notarization. + +### 3. Private-network client contract + +Workbench standardizes the customer path on Tailscale's signed Standalone macOS variant, which Tailscale currently recommends over the App Store and CLI-only variants. An already-installed compatible App Store variant is supported, but Workbench must detect the variant and must never install both variants on one Mac. The unattended CLI-only `tailscaled` variant is not a customer onboarding target. + +When the client is missing, the Workbench release manifest supplies an approved vendor package URL, exact version, digest, signing identity, and license/source notice. The **Install secure network** action downloads to a temporary private location, verifies the pinned identity, and opens the normal macOS installer. Workbench does not bypass Installer, VPN, system-extension, administrator, Touch ID, or System Settings consent. It deletes temporary material after success or cancellation and then re-probes the installed app. + +The main process locates the installed standalone or App Store bundle, invokes its bundled CLI through an explicit absolute path, and uses structured status output—not only the presence of a `100.x` interface—to classify state. It may prompt the client to install/approve its VPN or system extension through the vendor-supported flow. It never asks the customer to install CLI integration or type a terminal command. + +For Headscale enrollment, a user click authorizes a main-process call using the authenticated broker session. The main process invokes the vendor client with the approved custom login server and either an interactive browser login or a single-use short-lived enrollment key. The server URL and enrollment material never cross into renderer state or logs. Existing broker/support services remain responsible for minting, binding, expiring, and revoking enrollment material. + +Primary behavior references: [Tailscale macOS installation](https://tailscale.com/docs/install/mac), [Tailscale macOS variants](https://tailscale.com/docs/concepts/macos-variants), [Tailscale CLI](https://tailscale.com/docs/reference/tailscale-cli?tab=macos), and [Headscale Apple clients](https://headscale.net/stable/usage/connect/apple/). + +### 4. Typed prerequisite model + +The existing `evaos.native_companion_status.v1` response gains optional, backward-compatible prerequisite details instead of replacing the schema: + +```ts +type NativeCompanionPrerequisites = { + bridgeRuntime: 'missing' | 'incompatible' | 'ready' | 'error'; + privateNetwork: + | 'client_missing' + | 'client_stopped' + | 'unenrolled' + | 'wrong_control_plane' + | 'acl_blocked' + | 'offline' + | 'online' + | 'error'; + actionEngine: 'cua_ready' | 'peekaboo_ready' | 'native_fallback_ready' | 'unavailable'; +}; +``` + +The main process owns probing and classification. The renderer receives typed, redacted state only—never private addresses, control-plane URLs, enrollment credentials, or raw command output. Existing broad `secure_network_link_required` remains as a compatibility summary, while the optional prerequisite state selects precise copy and actions. + +### 5. Guided recovery actions + +Workbench presents the first unmet prerequisite with one primary action: + +| State | Customer action | Workbench behavior | +| --- | --- | --- | +| Bridge missing/incompatible | Reinstall/Update Workbench | Open the approved updater or public artifact; never request Python | +| Network client missing | Install secure network | Open the approved macOS installation surface and recheck after installation | +| Network client stopped | Open secure network | Open/activate the client and recheck | +| Unenrolled | Connect this Mac | Request short-lived broker-owned enrollment material only after the click; keep it in the main process and redact all output | +| Wrong control plane | Reconnect correctly | Fail closed, revoke unusable pending material, and run the approved re-enrollment flow | +| ACL blocked/offline | Retry or contact support | Report the exact layer without displaying addresses or ACL contents | +| CUA absent, Peekaboo ready | Continue | Mark CUA optional and automatically select bundled Peekaboo | +| No action engine | Repair Workbench | Fail before pairing/control, with a precise packaged-resource error | + +The first release does not silently install system extensions or enroll a Mac without a user click. Any privilege or macOS VPN approval remains an unavoidable signed OS prompt for the correct identity. + +### 6. Readiness composition + +The product keeps these gates separate: + +```text +installed app +→ self-contained bridge runnable +→ action-engine fallback available +→ TCC permissions correct +→ private network online and correctly enrolled +→ connector authenticated/reachable +→ broker grant active for exact identity +→ VM runtime/plugin configured +→ customer_mac_status succeeds +→ desktop_control_status succeeds +→ desktop_see succeeds +→ end-to-end ready +``` + +Local readiness may show progress but cannot produce the green end-to-end label. #655 remains the companion exit gate for exact-candidate, exact-identity live proof. + +## Process And Data Flow + +1. Main process resolves the bundled bridge at the existing resource path and runs a safe version/status preflight. +2. Main process probes the installed network client through explicit approved paths and maps output/errors to the typed prerequisite state. +3. Existing IPC/preload status transport carries only the backward-compatible redacted status object. +4. The renderer view model selects the first blocking prerequisite and corresponding i18n key/action. +5. A user action returns to the main process through the existing native-companion action bridge. +6. Enrollment material, if needed, is obtained through the authenticated broker session, used once in the main process, and never persisted in renderer state, logs, issue evidence, or analytics. +7. Status refreshes after each action. End-to-end readiness remains controlled by exact live runtime-tool proof. + +## Error And Security Behavior + +- Missing or incompatible bundled runtime is a release/package defect, not a customer prerequisite. +- Unknown network output maps to `error`, not `online` or `ready`. +- Stale/pending devices or grants cannot win selection over the exact active identity. +- Enrollment expiry, wrong control plane, ACL denial, offline peer, broker expiry, and runtime-tool failure remain distinct. +- Stop/revoke/kill-switch and offline transitions immediately clear live-ready state and fail closed. +- No issue, PR, test artifact, screenshot, log, diagnostic packet, or analytics payload may contain enrollment material, connector URLs, private addresses, ports, customer data, raw command output, or credentials. + +## Atomic Delivery Sequence + +### PR A — Desktop Bridge frozen payload producer + +Owner repo: `electricsheephq/evaos-desktop-bridge`, issue [#310](https://github.com/electricsheephq/evaos-desktop-bridge/issues/310). + +- Add the pinned one-directory macOS arm64 producer and immutable manifest. +- Include all normal and GUI fallback dependencies and licenses. +- Prove scrubbed-environment non-destructive commands. +- Publish only a reviewed candidate artifact; no Workbench/customer claim. + +### PR B — Workbench strict payload consumption + +Owner repo: `100yenadmin/evaOS-GUI`. + +- Add failing tests that strict release mode rejects the current shell bridge. +- Consume and manifest the pinned payload. +- Enforce architecture/digest/license checks in prepare, after-pack, after-sign, Functional Smoke, and release-asset inspection. +- Keep UI, private-network onboarding, and readiness behavior out of this PR. + +PR A and PR B may be developed in parallel against a fixture contract, but PR B cannot merge as a host-Python-removal claim until the real PR A artifact is pinned and its CI path is green. + +### PR C — Typed prerequisite classification + +- Add optional shared prerequisite types and pure classifiers. +- Cover missing/stopped/unenrolled/wrong-control-plane/ACL/offline/online/error states. +- Preserve compatibility summary reasons and broker/connector contracts. + +### PR D — Guided actions and localized UX + +- Add the minimum main-process actions and renderer recovery mapping. +- Reuse existing IPC/native-companion boundaries. +- Add every new key to every configured locale and regenerate types. +- Preserve explicit user approval for install/enroll operations. + +### PR E — Clean-host and pristine-Mac gates + +- Functional Smoke verifies Mach-O shape, architecture, signing, manifest identity, and safe startup with scrubbed `HOME`, `PATH`, and Python variables. +- Run supported-version clean-host lanes; Sonoma-only smoke is not supported-version proof. +- Run the exact signed/notarized candidate on a genuinely pristine supported Mac and separately on the prepared upgrade canary. +- Then execute #655 canonical live tools, approved low-impact action, stop/revoke, kill-switch, reboot/offline recovery, RC, update metadata, public URL, installed-app, dashboard-launch, and customer setup proof. + +## Test Strategy + +The first RED test is the current release-packaging contradiction: strict macOS packaging must reject a shell root bridge even when Peekaboo and the helper are native. + +```bash +bunx vitest run tests/unit/bootstrap/afterPackBundledResources.test.ts \ + -t "rejects a script desktop bridge for release-mode macOS builds" +``` + +Additional focused suites cover payload copy/manifest identity, architecture mismatch, digest mismatch, missing nested dependency, signature identity, scrubbed-environment startup, each private-network state, CUA absence with Peekaboo fallback, stale/pending identity selection, exact runtime proof, stop/revoke/kill-switch, and offline/reboot recovery. + +Broad checks and app builds run in GitHub Actions. Local checks remain focused in the isolated Lexar worktree. + +## Proof Boundaries + +- Unit tests prove source contracts only. +- Functional Smoke proves packaged resource shape and safe non-TCC startup for one named artifact only. +- Signing/notarization proves artifact identity, not permissions or Mac control. +- Prepared-Mac proof establishes upgrade/non-regression only. +- Pristine-Mac proof establishes clean onboarding only. +- Customer readiness requires pristine onboarding plus #655 exact-target live proof for the same signed candidate, followed by distribution and customer setup proof. + +## Non-Goals + +- No broad rebase or upstream UI port. +- No transport redesign, public connector endpoint, or Headscale ACL weakening. +- No Tailscale/Headscale replacement decision. +- No removal of Peekaboo fallback. +- No broad #480 contract refactor. +- No changes to Matt's setup. +- No release, updater, dashboard publish, customer send, or customer-ready claim during PR A–D. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md new file mode 100644 index 0000000000..3fa79a7906 --- /dev/null +++ b/docs/superpowers/specs/README.md @@ -0,0 +1,5 @@ +# Design Specs + +Validated implementation designs created through the repository's design-first workflow live here. Each spec must identify its owning issue, atomic delivery boundaries, validation gates, and proof limits. + +- [Workbench fresh-Mac zero-touch design](2026-07-12-workbench-fresh-mac-zero-touch-design.md) From 5d65322fd5f6e9b528a478a26f37e04f7c7a6065 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 17:05:04 +0700 Subject: [PATCH 2/6] fix(workbench): reject script bridge in release packages --- scripts/afterPack.js | 1 + tests/unit/bootstrap/afterPackBundledResources.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 78aa689f6b..c5fda98bd5 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -229,6 +229,7 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { + requireMachOExecutable(bridgePath, path.join('Bridge', 'evaos-desktop-bridge')); requireMachOExecutable(peekabooPath, path.join('Bridge', 'bin', 'peekaboo')); requireMachOExecutable(helperPath, path.join('Bridge', 'bin', 'evaos-connector-helper')); } diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 3db7f88a72..362c3b0f83 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -184,7 +184,7 @@ describe('afterPack bundled resource verification', () => { } }); - it('accepts native control helper resources for release-mode macOS builds', () => { + it('rejects a script desktop bridge for release-mode macOS builds', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); @@ -195,7 +195,9 @@ describe('afterPack bundled resource verification', () => { expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'))).toBe(false); expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'peekaboo'))).toBe(true); expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'evaos-connector-helper'))).toBe(true); - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).not.toThrow(); + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( + /Bridge\/evaos-desktop-bridge/ + ); } finally { restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); } From 9401921cf0430fc5b0123f3b82339cd828d72012 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 17:21:29 +0700 Subject: [PATCH 3/6] feat(workbench): consume pinned desktop bridge payloads --- scripts/afterPack.js | 41 +++ scripts/afterSign.js | 1 + scripts/evaosBetaReleaseGate.js | 5 +- scripts/prepareEvaosDesktopBridgeResource.js | 257 +++++++++++++++++ .../afterPackBundledResources.test.ts | 111 +++++++- .../unit/process/evaosBetaReleaseGate.test.ts | 20 +- .../prepareEvaosDesktopBridgeResource.test.ts | 258 +++++++++++++++++- 7 files changed, 684 insertions(+), 9 deletions(-) diff --git a/scripts/afterPack.js b/scripts/afterPack.js index c5fda98bd5..9527daeae9 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,4 +1,5 @@ const { Arch } = require('builder-util'); +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -9,6 +10,7 @@ const { getModulesToRebuild, } = require('./rebuildNativeModules'); const { normalizeManagedResourcesBundle } = require('../packages/shared-scripts/src/prepare-aioncore.js'); +const { computePayloadTreeDigest } = require('./prepareEvaosDesktopBridgeResource.js'); /** * afterPack hook for electron-builder @@ -156,6 +158,44 @@ function requireMachOExecutable(filePath, relativePath) { ); } +function requirePinnedBridgePayloadIdentity(resourcesDir, manifest) { + const bridgeDir = path.join(resourcesDir, 'Bridge'); + const producerManifestPath = path.join(bridgeDir, 'payload-manifest.json'); + const payloadSha256 = String(manifest?.payload?.sha256 || '') + .trim() + .toLowerCase(); + const validIdentity = + manifest?.schema === 'evaos-desktop-bridge-resource/v2' && + manifest?.placeholder === false && + manifest?.producerManifest === 'payload-manifest.json' && + /^[0-9a-f]{64}$/.test( + String(manifest?.producerManifestSha256 || '') + .trim() + .toLowerCase() + ) && + /^[0-9a-f]{40}$/i.test(String(manifest?.sourceCommit || '')) && + manifest?.payload?.algorithm === 'sha256-tree-v1' && + /^[0-9a-f]{64}$/.test(payloadSha256) && + manifest?.payload?.target?.platform === 'macos' && + manifest?.payload?.target?.architecture === 'arm64' && + Number.isInteger(manifest?.payload?.fileCount) && + fs.existsSync(producerManifestPath); + if (!validIdentity) { + throw new Error('Release macOS bridge resource is missing its pinned immutable payload identity.'); + } + const producerManifestSha256 = crypto + .createHash('sha256') + .update(fs.readFileSync(producerManifestPath)) + .digest('hex'); + if (producerManifestSha256 !== manifest.producerManifestSha256) { + throw new Error('Release macOS bridge resource producer manifest digest does not match its approved pin.'); + } + const actualPayload = computePayloadTreeDigest(bridgeDir); + if (actualPayload.sha256 !== payloadSha256 || actualPayload.fileCount !== manifest.payload.fileCount) { + throw new Error('Release macOS bridge resource does not match its pinned immutable payload identity.'); + } +} + function requireManagedNodeRuntime(resourcesDir, runtimeKey, electronPlatformName, missing) { const executableParts = getManagedNodeExecutableParts(electronPlatformName); const relativePath = path.join('bundled-aioncore', runtimeKey, 'managed-resources', 'node', '*', ...executableParts); @@ -229,6 +269,7 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { + requirePinnedBridgePayloadIdentity(resourcesDir, manifest); requireMachOExecutable(bridgePath, path.join('Bridge', 'evaos-desktop-bridge')); requireMachOExecutable(peekabooPath, path.join('Bridge', 'bin', 'peekaboo')); requireMachOExecutable(helperPath, path.join('Bridge', 'bin', 'evaos-connector-helper')); diff --git a/scripts/afterSign.js b/scripts/afterSign.js index 4ef4fdd6a0..54ec7b5041 100644 --- a/scripts/afterSign.js +++ b/scripts/afterSign.js @@ -25,6 +25,7 @@ const DEFAULT_APP_TRUST_PROCESS_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_EXPECTED_TEAM_ID = 'TC6MS3T6NN'; const MACHO_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'cafebabf']); const MAC_CONTROL_HELPER_RELATIVE_PATHS = [ + path.join('Contents', 'Resources', 'Bridge', 'evaos-desktop-bridge'), path.join('Contents', 'Resources', 'Bridge', 'bin', 'peekaboo'), path.join('Contents', 'Resources', 'Bridge', 'bin', 'evaos-connector-helper'), ]; diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 57c54ea730..5211fe2832 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -1540,7 +1540,7 @@ function inspectMacosZipBridgePayload(zipPath) { ' if part.endswith(".app") and parts[index + 1:index + 4] == ["Contents", "Resources", "Bridge"]:', ' return "/".join(parts[index + 4:])', ' return None', - 'result = {"hasBridgeExecutable": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', + 'result = {"hasBridgeExecutable": False, "bridgeExecutableMachO": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', 'entries = {}', 'with zipfile.ZipFile(path) as archive:', ' for name in archive.namelist():', @@ -1552,6 +1552,8 @@ function inspectMacosZipBridgePayload(zipPath) { ' result["hasPeekaboo"] = "bin/peekaboo" in entries', ' result["hasConnectorHelper"] = "bin/evaos-connector-helper" in entries', ' result["hasPeekabooLicense"] = expected_license_path in entries', + ' if result["hasBridgeExecutable"]:', + ' result["bridgeExecutableMachO"] = archive.read(entries["evaos-desktop-bridge"], pwd=None)[:4].hex() in macho_magics', ' manifest = {}', ' if result["hasBridgeManifest"]:', ' try:', @@ -1608,6 +1610,7 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { for (const zipName of zipNames) { const probe = inspectMacosZipBridgePayload(path.join(outputDir, zipName)); assertZipBridgeProbe(probe, 'hasBridgeExecutable', zipName, 'executable'); + assertZipBridgeProbe(probe, 'bridgeExecutableMachO', zipName, 'executable Mach-O shape'); assertZipBridgeProbe(probe, 'hasBridgeManifest', zipName, 'manifest'); assertZipBridgeProbe(probe, 'hasPeekaboo', zipName, 'Peekaboo binary'); assertZipBridgeProbe(probe, 'peekabooMachO', zipName, 'Peekaboo binary Mach-O shape'); diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index 00437bf973..ed2764d298 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -12,6 +12,8 @@ const defaultBridgeSourceRepo = 'https://github.com/electricsheephq/evaos-deskto const defaultBridgeSourceRef = 'main'; const PLACEHOLDER_SOURCE = 'diagnostic-placeholder'; const PEEKABOO_LICENSE_RELATIVE_PATH = 'licenses/Peekaboo-LICENSE.txt'; +const PAYLOAD_MANIFEST_FILENAME = 'payload-manifest.json'; +const RESOURCE_MANIFEST_FILENAME = 'manifest.json'; const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on', 'evaos-beta']); const MACHO_MAGICS = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'cafebabf']); @@ -36,6 +38,31 @@ function shouldRequireRealBridge() { ); } +function resolvePinnedBridgeConfiguration(env = process.env, strictRelease = shouldRequireRealBridge()) { + const payloadDir = String(env.EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR || '').trim(); + const expectedPayloadSha256 = String(env.EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256 || '') + .trim() + .toLowerCase(); + const expectedManifestSha256 = String(env.EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256 || '') + .trim() + .toLowerCase(); + if (!payloadDir) { + if (strictRelease) { + throw new Error( + 'Release builds require a pinned self-contained Desktop Bridge payload; the legacy host-Python source wrapper is not releaseable.' + ); + } + return undefined; + } + if (!expectedPayloadSha256) { + throw new Error('Pinned Desktop Bridge payload preparation requires EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256.'); + } + if (!expectedManifestSha256) { + throw new Error('Pinned Desktop Bridge payload preparation requires EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256.'); + } + return { payloadDir, expectedPayloadSha256, expectedManifestSha256 }; +} + function selectedBridgeSourceRef() { return String(process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || '').trim() || defaultBridgeSourceRef; } @@ -375,6 +402,221 @@ function sha256File(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +function payloadRelativePath(relativePath) { + return relativePath.split(path.sep).join('/'); +} + +function listPayloadFiles(payloadDir, relativeDir = '') { + const currentDir = path.join(payloadDir, relativeDir); + const files = []; + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const relativePath = path.join(relativeDir, entry.name); + const absolutePath = path.join(payloadDir, relativePath); + const stat = fs.lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + throw new Error(`Desktop Bridge payload must not contain symbolic links: ${payloadRelativePath(relativePath)}`); + } + if (stat.isDirectory()) { + files.push(...listPayloadFiles(payloadDir, relativePath)); + continue; + } + if (!stat.isFile()) { + throw new Error(`Desktop Bridge payload contains a non-regular file: ${payloadRelativePath(relativePath)}`); + } + if (![PAYLOAD_MANIFEST_FILENAME, RESOURCE_MANIFEST_FILENAME].includes(payloadRelativePath(relativePath))) { + files.push({ absolutePath, relativePath: payloadRelativePath(relativePath), stat }); + } + } + return files; +} + +function computePayloadTreeDigest(payloadDir) { + const files = listPayloadFiles(payloadDir).toSorted((left, right) => + Buffer.compare(Buffer.from(left.relativePath, 'utf8'), Buffer.from(right.relativePath, 'utf8')) + ); + const records = files + .map(({ absolutePath, relativePath, stat }) => { + const normalizedMode = stat.mode & 0o111 ? '0755' : '0644'; + return `${relativePath}\0${normalizedMode}\0${sha256File(absolutePath)}\n`; + }) + .join(''); + return { + algorithm: 'sha256-tree-v1', + sha256: crypto.createHash('sha256').update(records).digest('hex'), + fileCount: files.length, + }; +} + +function requireSha256(value, description) { + const digest = String(value || '') + .trim() + .toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(digest)) { + throw new Error(`Desktop Bridge payload requires a valid ${description} SHA-256 digest.`); + } + return digest; +} + +function resolvePayloadFile(payloadDir, relativePath, description) { + const normalized = payloadRelativePath(String(relativePath || '').trim()); + if (!normalized || path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) { + throw new Error(`Desktop Bridge payload has an invalid ${description} path.`); + } + const payloadRoot = path.resolve(payloadDir); + const absolutePath = path.resolve(payloadRoot, normalized); + if (!absolutePath.startsWith(`${payloadRoot}${path.sep}`)) { + throw new Error(`Desktop Bridge payload ${description} escapes the payload root.`); + } + const stat = fs.lstatSync(absolutePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Desktop Bridge payload ${description} must be a regular file: ${normalized}`); + } + return { absolutePath, relativePath: normalized }; +} + +function isArm64MachOExecutable(filePath) { + if (!isMachOExecutable(filePath)) return false; + const header = fs.readFileSync(filePath).subarray(0, 12); + if (header.length < 12) return false; + const magic = header.subarray(0, 4).toString('hex'); + if (magic === 'cffaedfe') return header.readUInt32LE(4) === 0x0100000c; + if (magic === 'feedfacf') return header.readUInt32BE(4) === 0x0100000c; + return false; +} + +function verifyPayloadFile(payloadDir, entry, expectedPath, description, options = {}) { + if (!entry || typeof entry !== 'object') { + throw new Error(`Desktop Bridge payload manifest is missing ${description}.`); + } + const resolved = resolvePayloadFile(payloadDir, entry.path, description); + if (expectedPath && resolved.relativePath !== expectedPath) { + throw new Error(`Desktop Bridge payload ${description} must use ${expectedPath}.`); + } + const expectedSha256 = requireSha256(entry.sha256, `${description} file`); + const actualSha256 = sha256File(resolved.absolutePath); + if (actualSha256 !== expectedSha256) { + throw new Error(`Desktop Bridge payload ${description} digest does not match its manifest.`); + } + if (options.arm64MachO && !isArm64MachOExecutable(resolved.absolutePath)) { + throw new Error(`Desktop Bridge payload ${description} must be an arm64 Mach-O executable.`); + } + return { ...resolved, sha256: actualSha256 }; +} + +function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expectedManifestSha256) { + if (fs.existsSync(path.join(payloadDir, RESOURCE_MANIFEST_FILENAME))) { + throw new Error(`Desktop Bridge producer payload must not contain reserved ${RESOURCE_MANIFEST_FILENAME}.`); + } + const manifestPath = path.join(payloadDir, PAYLOAD_MANIFEST_FILENAME); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Desktop Bridge payload is missing ${PAYLOAD_MANIFEST_FILENAME}.`); + } + const pinnedManifestSha256 = requireSha256(expectedManifestSha256, 'out-of-band manifest'); + if (sha256File(manifestPath) !== pinnedManifestSha256) { + throw new Error('Desktop Bridge payload manifest digest does not match the approved release pin.'); + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error(`Desktop Bridge payload manifest is unreadable: ${error.message}`); + } + + if (manifest.schema_version !== 1) throw new Error('Desktop Bridge payload schema_version must be 1.'); + if ( + manifest.artifact?.id !== 'evaos-desktop-bridge-macos-arm64' || + manifest.artifact?.format !== 'onedir' || + manifest.artifact?.root_executable !== 'evaos-desktop-bridge' + ) { + throw new Error('Desktop Bridge payload artifact identity is not the approved macOS arm64 one-directory shape.'); + } + if (manifest.target?.platform !== 'macos' || manifest.target?.architecture !== 'arm64') { + throw new Error('Desktop Bridge payload target must be macos/arm64.'); + } + if ( + manifest.source?.repository !== 'electricsheephq/evaos-desktop-bridge' || + !/^[0-9a-f]{40}$/i.test(String(manifest.source?.commit || '')) || + !String(manifest.source?.version || '').trim() + ) { + throw new Error('Desktop Bridge payload source identity is incomplete or mutable.'); + } + if ( + !String(manifest.toolchain?.python || '').trim() || + !String(manifest.toolchain?.freezer?.name || '').trim() || + !String(manifest.toolchain?.freezer?.version || '').trim() || + !Array.isArray(manifest.toolchain?.dependencies) + ) { + throw new Error('Desktop Bridge payload toolchain identity is incomplete.'); + } + + const expectedSha256 = requireSha256(expectedPayloadSha256, 'out-of-band payload'); + const recordedSha256 = requireSha256(manifest.payload?.sha256, 'manifest payload'); + if (manifest.payload?.algorithm !== 'sha256-tree-v1' || recordedSha256 !== expectedSha256) { + throw new Error('Desktop Bridge payload identity does not match the approved release pin.'); + } + const actualPayload = computePayloadTreeDigest(payloadDir); + if (actualPayload.sha256 !== expectedSha256 || actualPayload.fileCount !== manifest.payload?.file_count) { + throw new Error('Desktop Bridge payload tree does not match its immutable manifest identity.'); + } + + verifyPayloadFile(payloadDir, manifest.files?.root_executable, 'evaos-desktop-bridge', 'root executable', { + arm64MachO: true, + }); + verifyPayloadFile(payloadDir, manifest.files?.peekaboo, 'bin/peekaboo', 'Peekaboo executable', { + arm64MachO: true, + }); + verifyPayloadFile(payloadDir, manifest.files?.connector_helper, 'bin/evaos-connector-helper', 'connector helper', { + arm64MachO: true, + }); + if (!Array.isArray(manifest.files?.licenses) || manifest.files.licenses.length === 0) { + throw new Error('Desktop Bridge payload must include at least one recorded license.'); + } + for (const [index, license] of manifest.files.licenses.entries()) { + if (!String(license?.name || '').trim() || !String(license?.path || '').startsWith('licenses/')) { + throw new Error(`Desktop Bridge payload license ${index + 1} has invalid identity.`); + } + verifyPayloadFile(payloadDir, license, undefined, `license ${index + 1}`); + } + const signingInputs = Array.isArray(manifest.signing?.inputs) ? manifest.signing.inputs : []; + for (const requiredInput of ['evaos-desktop-bridge', 'bin/peekaboo', 'bin/evaos-connector-helper']) { + if (manifest.signing?.state !== 'unsigned' || !signingInputs.includes(requiredInput)) { + throw new Error('Desktop Bridge payload signing inputs are incomplete.'); + } + } + return { manifest, payload: actualPayload }; +} + +function preparePinnedBridgePayload(payloadDir, resourceDir, expectedPayloadSha256, expectedManifestSha256) { + const sourceDir = path.resolve(payloadDir); + const targetDir = path.resolve(resourceDir); + const validated = validatePinnedBridgePayload(sourceDir, expectedPayloadSha256, expectedManifestSha256); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true, preserveTimestamps: true }); + const copiedPayload = computePayloadTreeDigest(targetDir); + if (copiedPayload.sha256 !== validated.payload.sha256 || copiedPayload.fileCount !== validated.payload.fileCount) { + throw new Error('Copied Desktop Bridge payload does not match its verified source identity.'); + } + const resourceManifest = { + schema: 'evaos-desktop-bridge-resource/v2', + placeholder: false, + producerManifest: PAYLOAD_MANIFEST_FILENAME, + producerManifestSha256: requireSha256(expectedManifestSha256, 'out-of-band manifest'), + sourceCommit: validated.manifest.source.commit, + sourceVersion: validated.manifest.source.version, + payload: { + algorithm: copiedPayload.algorithm, + sha256: copiedPayload.sha256, + fileCount: copiedPayload.fileCount, + target: validated.manifest.target, + rootSha256: validated.manifest.files.root_executable.sha256, + }, + generatedAt: new Date().toISOString(), + }; + fs.writeFileSync(path.join(targetDir, RESOURCE_MANIFEST_FILENAME), `${JSON.stringify(resourceManifest, null, 2)}\n`); + return resourceManifest; +} + function peekabooIdentity(filePath, execute = execFileSync) { const output = execute(filePath, ['--version'], { encoding: 'utf8', @@ -472,6 +714,18 @@ function removePycache(dir) { } function main() { + const payloadConfiguration = resolvePinnedBridgeConfiguration(); + if (payloadConfiguration) { + preparePinnedBridgePayload( + payloadConfiguration.payloadDir, + bridgeResourceDir, + payloadConfiguration.expectedPayloadSha256, + payloadConfiguration.expectedManifestSha256 + ); + console.log(`Prepared pinned evaOS Desktop Bridge payload at ${bridgeResourceDir}`); + return; + } + let bridgeSourceDir; try { bridgeSourceDir = resolveBridgeSourceDir(); @@ -529,10 +783,13 @@ if (require.main === module) { module.exports = { bridgeManifest, bridgeWrapperScript, + computePayloadTreeDigest, installPeekabooLicense, isMachOExecutable, peekabooBundleMetadata, peekabooIdentity, + preparePinnedBridgePayload, + resolvePinnedBridgeConfiguration, resolveBridgeSourceDir, shouldCloneBridgeRefAsBranch, sourceCandidates, diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 362c3b0f83..2b8b00ad7b 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -1,4 +1,5 @@ import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +11,9 @@ const afterPack = require('../../../scripts/afterPack.js') as { verifyBundledResources: (resourcesDir: string, electronPlatformName: string, targetArch: string) => void; verifyEvaosDesktopBridgeResource: (resourcesDir: string, electronPlatformName: string) => void; }; +const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { + computePayloadTreeDigest: (payloadDir: string) => { algorithm: string; sha256: string; fileCount: number }; +}; const tempDirs: string[] = []; @@ -42,18 +46,31 @@ function writeMachOFixture(path: string): void { chmodSync(path, 0o755); } -function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; nativeHelpers?: boolean } = {}): void { +function writeBridgeFixture( + resourcesDir: string, + options: { + helper?: boolean; + nativeHelpers?: boolean; + nativeRoot?: boolean; + pinnedPayload?: boolean; + payloadSha256?: string; + producerManifestSha256?: string; + } = {} +): void { const bridgeDir = join(resourcesDir, 'Bridge'); mkdirSync(join(bridgeDir, 'bin'), { recursive: true }); const bridgePath = join(bridgeDir, 'evaos-desktop-bridge'); const peekabooPath = join(bridgeDir, 'bin', 'peekaboo'); - writeExecutableScript(bridgePath); + if (options.nativeRoot) { + writeMachOFixture(bridgePath); + } else { + writeExecutableScript(bridgePath); + } if (options.nativeHelpers) { writeMachOFixture(peekabooPath); } else { writeExecutableScript(peekabooPath); } - writeFileSync(join(bridgeDir, 'manifest.json'), '{"placeholder":false}\n'); if (options.helper) { const helperPath = join(bridgeDir, 'bin', 'evaos-connector-helper'); if (options.nativeHelpers) { @@ -62,6 +79,30 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n writeExecutableScript(helperPath); } } + if (options.pinnedPayload) { + const producerManifest = '{"schema_version":1}\n'; + writeFileSync(join(bridgeDir, 'payload-manifest.json'), producerManifest); + const payload = bridgeResource.computePayloadTreeDigest(bridgeDir); + writeFileSync( + join(bridgeDir, 'manifest.json'), + `${JSON.stringify({ + schema: 'evaos-desktop-bridge-resource/v2', + placeholder: false, + producerManifest: 'payload-manifest.json', + producerManifestSha256: + options.producerManifestSha256 || createHash('sha256').update(producerManifest).digest('hex'), + sourceCommit: '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b', + payload: { + algorithm: payload.algorithm, + sha256: options.payloadSha256 || payload.sha256, + fileCount: payload.fileCount, + target: { platform: 'macos', architecture: 'arm64' }, + }, + })}\n` + ); + } else { + writeFileSync(join(bridgeDir, 'manifest.json'), '{"placeholder":false}\n'); + } } afterEach(() => { @@ -171,7 +212,7 @@ describe('afterPack bundled resource verification', () => { it('rejects script control helper resources for release-mode macOS builds', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { helper: true }); + writeBridgeFixture(resourcesDir, { helper: true, nativeRoot: true, pinnedPayload: true }); try { process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; @@ -187,7 +228,7 @@ describe('afterPack bundled resource verification', () => { it('rejects a script desktop bridge for release-mode macOS builds', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, pinnedPayload: true }); try { process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; @@ -203,6 +244,66 @@ describe('afterPack bundled resource verification', () => { } }); + it('rejects a release payload whose immutable tree digest is tampered', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + payloadSha256: '0'.repeat(64), + }); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( + /immutable payload identity/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('rejects a release payload whose producer manifest digest is tampered', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + producerManifestSha256: '0'.repeat(64), + }); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( + /producer manifest digest/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + + it('accepts a pinned native desktop bridge payload for release-mode macOS builds', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + }); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).not.toThrow(); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + it('requires the evaOS connector binary in macOS bridge resources', () => { const resourcesDir = makeTempResources(); writeBridgeFixture(resourcesDir, { helper: true }); diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index ce04e2d6ca..aab0755410 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -127,6 +127,7 @@ function writeMacosBridgeZip( extraEntryCount?: number; omitPeekaboo?: boolean; omitLicense?: boolean; + scriptBridge?: boolean; sourceSha256?: string; manifestLicenseSha256?: string; } = {} @@ -143,12 +144,14 @@ function writeMacosBridgeZip( 'omit_license = sys.argv[4] == "1"', 'source_sha256 = sys.argv[5]', 'manifest_license_sha256 = sys.argv[6]', + 'script_bridge = sys.argv[7] == "1"', 'app_root = zip_path.stem.replace("-mac-arm64", "").replace("-mac-x64", "") + ".app"', 'license_bytes = b"MIT License\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\n"', 'license_sha256 = manifest_license_sha256 or hashlib.sha256(license_bytes).hexdigest()', 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}}}', 'with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:', - ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/evaos-desktop-bridge", "#!/usr/bin/env bash\\n")', + ' bridge_bytes = b"#!/usr/bin/env bash\\n" if script_bridge else bytes.fromhex("cffaedfe0c00000100000000")', + ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/evaos-desktop-bridge", bridge_bytes)', ' if not omit_peekaboo:', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/peekaboo", bytes.fromhex("cafebabe00000000"))', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"))', @@ -167,6 +170,7 @@ function writeMacosBridgeZip( options.omitLicense ? '1' : '0', options.sourceSha256 || '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383', options.manifestLicenseSha256 || '', + options.scriptBridge ? '1' : '0', ]); } @@ -475,8 +479,10 @@ describe('evaOS beta release gate', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evaos-after-sign-helpers-')); const appPath = path.join(dir, 'evaOS Workbench.app'); const helperDir = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'bin'); + const bridgePath = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'evaos-desktop-bridge'); const peekabooPath = path.join(helperDir, 'peekaboo'); const connectorHelperPath = path.join(helperDir, 'evaos-connector-helper'); + const inspectedPaths: string[] = []; const signedByExpectedTeam = { status: 0, stdout: '', @@ -491,6 +497,7 @@ describe('evaOS beta release gate', () => { }; try { + writeMachOFixture(bridgePath); writeMachOFixture(peekabooPath); writeMachOFixture(connectorHelperPath); @@ -501,9 +508,13 @@ describe('evaOS beta release gate', () => { EVAOS_MAC_CONTROL_HELPER_TEAM_ID: 'TC6MS3T6NN', EVAOS_MAC_CONTROL_HELPER_AUTHORITY: 'Developer ID Application: Andrew Ryan (TC6MS3T6NN)', }, - () => signedByExpectedTeam + (_command, args) => { + inspectedPaths.push(args.at(-1) || ''); + return signedByExpectedTeam; + } ) ).not.toThrow(); + expect(inspectedPaths).toEqual([bridgePath, peekabooPath, connectorHelperPath]); expect(() => afterSign.assertMacControlHelperSignatures( @@ -1431,6 +1442,11 @@ describe('evaOS beta release gate', () => { it('rejects macOS release ZIPs without exact Peekaboo package proof', () => { const cases = [ + { + name: 'script root bridge', + options: { scriptBridge: true }, + expected: /executable Mach-O shape/, + }, { name: 'missing binary', options: { omitPeekaboo: true }, diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index 195ae7902b..b7998d642e 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -1,4 +1,13 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -24,6 +33,11 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour }; }) => Record; bridgeWrapperScript: () => string; + computePayloadTreeDigest: (payloadDir: string) => { + algorithm: 'sha256-tree-v1'; + sha256: string; + fileCount: number; + }; isMachOExecutable: (filePath: string) => boolean; installPeekabooLicense: ( sourcePath?: string, @@ -40,11 +54,176 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour resourceDir?: string ) => { peekaboo: Record } | undefined; peekabooIdentity: (filePath: string, execute?: PeekabooVersionRunner) => { version: string; sourceSha256: string }; + preparePinnedBridgePayload: ( + payloadDir: string, + resourceDir: string, + expectedPayloadSha256: string, + expectedManifestSha256: string + ) => Record; + resolvePinnedBridgeConfiguration: ( + env: Record, + strictRelease: boolean + ) => { payloadDir: string; expectedPayloadSha256: string; expectedManifestSha256: string } | undefined; shouldCloneBridgeRefAsBranch: (ref: string) => boolean; sourceCandidates: () => string[]; }; describe('prepareEvaosDesktopBridgeResource', () => { + it('computes the producer sha256-tree-v1 digest without trusting its manifest', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-payload-digest-')); + const bridgeContents = Buffer.from('cffaedfe0c00000100000000', 'hex'); + const licenseContents = 'bridge license\n'; + try { + mkdirSync(join(dir, 'licenses'), { recursive: true }); + writeFileSync(join(dir, 'evaos-desktop-bridge'), bridgeContents); + chmodSync(join(dir, 'evaos-desktop-bridge'), 0o775); + writeFileSync(join(dir, 'licenses', 'LICENSE'), licenseContents); + chmodSync(join(dir, 'licenses', 'LICENSE'), 0o664); + writeFileSync(join(dir, 'payload-manifest.json'), '{"untrusted":true}\n'); + + const bridgeSha = createHash('sha256').update(bridgeContents).digest('hex'); + const licenseSha = createHash('sha256').update(licenseContents).digest('hex'); + const records = [ + `${['evaos-desktop-bridge', '0755', bridgeSha].join('\0')}\n`, + `${['licenses/LICENSE', '0644', licenseSha].join('\0')}\n`, + ].join(''); + + expect(bridgeResource.computePayloadTreeDigest(dir)).toEqual({ + algorithm: 'sha256-tree-v1', + sha256: createHash('sha256').update(records).digest('hex'), + fileCount: 2, + }); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('copies a pinned self-contained payload and records its immutable identity', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-payload-copy-')); + const payloadDir = join(dir, 'payload'); + const resourceDir = join(dir, 'Bridge'); + try { + const { manifestSha256, payloadSha256, sourceCommit } = writePinnedPayloadFixture(payloadDir); + + expect( + bridgeResource.preparePinnedBridgePayload(payloadDir, resourceDir, payloadSha256, manifestSha256) + ).toMatchObject({ + schema: 'evaos-desktop-bridge-resource/v2', + placeholder: false, + sourceCommit, + producerManifest: 'payload-manifest.json', + producerManifestSha256: manifestSha256, + payload: { + algorithm: 'sha256-tree-v1', + sha256: payloadSha256, + target: { platform: 'macos', architecture: 'arm64' }, + }, + }); + + expect(existsSync(join(resourceDir, 'evaos-desktop-bridge'))).toBe(true); + expect(existsSync(join(resourceDir, '_internal', 'runtime.dat'))).toBe(true); + expect(existsSync(join(resourceDir, 'payload-manifest.json'))).toBe(true); + expect(JSON.parse(readFileSync(join(resourceDir, 'manifest.json'), 'utf8'))).toMatchObject({ + sourceCommit, + payload: { sha256: payloadSha256 }, + }); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a payload that differs from the approved out-of-band digest', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-payload-pin-')); + try { + const payloadDir = join(dir, 'payload'); + const { manifestSha256 } = writePinnedPayloadFixture(payloadDir); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), '0'.repeat(64), manifestSha256) + ).toThrow(/approved release pin/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a pinned payload whose root executable is a script', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-payload-script-')); + try { + const payloadDir = join(dir, 'payload'); + const { manifestSha256, payloadSha256 } = writePinnedPayloadFixture(payloadDir, { rootMachO: false }); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/root executable must be an arm64 Mach-O executable/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects symbolic links in an otherwise pinned payload', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-payload-symlink-')); + try { + writeFileSync(join(dir, 'target'), 'unexpected external target'); + symlinkSync(join(dir, 'target'), join(dir, 'link')); + + expect(() => bridgeResource.computePayloadTreeDigest(dir)).toThrow(/must not contain symbolic links/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects a manifest-only mutation against the out-of-band manifest digest', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-manifest-pin-')); + try { + const payloadDir = join(dir, 'payload'); + const { manifestSha256, payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.toolchain.python = 'unexpected-python'; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/manifest digest/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('refuses the legacy host-Python wrapper path for strict release preparation', () => { + expect(() => + bridgeResource.resolvePinnedBridgeConfiguration( + { EVAOS_DESKTOP_BRIDGE_SOURCE_REF: '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b' }, + true + ) + ).toThrow(/self-contained Desktop Bridge payload/); + }); + + it('requires an out-of-band digest whenever a pinned payload is configured', () => { + expect(() => + bridgeResource.resolvePinnedBridgeConfiguration( + { EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR: '/tmp/evaos-desktop-bridge-payload' }, + false + ) + ).toThrow(/EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256/); + }); + + it('requires a separate manifest digest for a pinned payload', () => { + expect(() => + bridgeResource.resolvePinnedBridgeConfiguration( + { + EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR: '/tmp/evaos-desktop-bridge-payload', + EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256: '1'.repeat(64), + }, + false + ) + ).toThrow(/EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256/); + }); + + it('keeps the legacy source wrapper available only for non-release development', () => { + expect(bridgeResource.resolvePinnedBridgeConfiguration({}, false)).toBeUndefined(); + }); + it('isolates the packaged desktop bridge wrapper from ambient Python paths', () => { const wrapper = bridgeResource.bridgeWrapperScript(); @@ -264,6 +443,83 @@ describe('prepareEvaosDesktopBridgeResource', () => { }); }); +function writePinnedPayloadFixture( + payloadDir: string, + options: { rootMachO?: boolean } = {} +): { manifestSha256: string; payloadSha256: string; sourceCommit: string } { + const sourceCommit = '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b'; + const machO = Buffer.from('cffaedfe0c00000100000000', 'hex'); + const license = 'MIT License\n\nPermission is hereby granted\n'; + mkdirSync(join(payloadDir, '_internal'), { recursive: true }); + mkdirSync(join(payloadDir, 'bin'), { recursive: true }); + mkdirSync(join(payloadDir, 'licenses'), { recursive: true }); + for (const relativePath of ['bin/peekaboo', 'bin/evaos-connector-helper']) { + writeFileSync(join(payloadDir, relativePath), machO); + chmodSync(join(payloadDir, relativePath), 0o755); + } + writeFileSync(join(payloadDir, 'evaos-desktop-bridge'), options.rootMachO === false ? '#!/bin/sh\nexit 0\n' : machO); + chmodSync(join(payloadDir, 'evaos-desktop-bridge'), 0o755); + writeFileSync(join(payloadDir, '_internal', 'runtime.dat'), 'private runtime\n'); + writeFileSync(join(payloadDir, 'licenses', 'LICENSE'), license); + + const payload = bridgeResource.computePayloadTreeDigest(payloadDir); + const sha256 = (relativePath: string) => + createHash('sha256') + .update(readFileSync(join(payloadDir, relativePath))) + .digest('hex'); + writeFileSync( + join(payloadDir, 'payload-manifest.json'), + `${JSON.stringify( + { + schema_version: 1, + artifact: { + id: 'evaos-desktop-bridge-macos-arm64', + format: 'onedir', + root_executable: 'evaos-desktop-bridge', + }, + target: { platform: 'macos', architecture: 'arm64' }, + source: { + repository: 'electricsheephq/evaos-desktop-bridge', + commit: sourceCommit, + version: '0.7.0', + }, + toolchain: { + python: '3.12.12', + freezer: { name: 'pyinstaller', version: '6.16.0' }, + dependencies: [{ name: 'pyobjc-core', version: '11.1' }], + }, + files: { + root_executable: { path: 'evaos-desktop-bridge', sha256: sha256('evaos-desktop-bridge') }, + peekaboo: { path: 'bin/peekaboo', sha256: sha256('bin/peekaboo') }, + connector_helper: { + path: 'bin/evaos-connector-helper', + sha256: sha256('bin/evaos-connector-helper'), + }, + licenses: [{ name: 'Desktop Bridge', path: 'licenses/LICENSE', sha256: sha256('licenses/LICENSE') }], + }, + payload: { + algorithm: payload.algorithm, + sha256: payload.sha256, + file_count: payload.fileCount, + }, + signing: { + state: 'unsigned', + inputs: ['evaos-desktop-bridge', 'bin/peekaboo', 'bin/evaos-connector-helper'], + }, + }, + null, + 2 + )}\n` + ); + return { + manifestSha256: createHash('sha256') + .update(readFileSync(join(payloadDir, 'payload-manifest.json'))) + .digest('hex'), + payloadSha256: payload.sha256, + sourceCommit, + }; +} + function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { delete process.env[name]; From b8ed3108ebf191175f3f80d1145aebdfc4cb55b0 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 17:24:42 +0700 Subject: [PATCH 4/6] fix(workbench): align bridge payload contract --- scripts/prepareEvaosDesktopBridgeResource.js | 29 ++++++++++++++++++- .../prepareEvaosDesktopBridgeResource.test.ts | 26 +++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index ed2764d298..0d64effd76 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -12,6 +12,7 @@ const defaultBridgeSourceRepo = 'https://github.com/electricsheephq/evaos-deskto const defaultBridgeSourceRef = 'main'; const PLACEHOLDER_SOURCE = 'diagnostic-placeholder'; const PEEKABOO_LICENSE_RELATIVE_PATH = 'licenses/Peekaboo-LICENSE.txt'; +const DEFAULT_REQUIRED_PEEKABOO_VERSION = '3.8.0'; const PAYLOAD_MANIFEST_FILENAME = 'payload-manifest.json'; const RESOURCE_MANIFEST_FILENAME = 'manifest.json'; @@ -562,7 +563,24 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected verifyPayloadFile(payloadDir, manifest.files?.root_executable, 'evaos-desktop-bridge', 'root executable', { arm64MachO: true, }); - verifyPayloadFile(payloadDir, manifest.files?.peekaboo, 'bin/peekaboo', 'Peekaboo executable', { + const peekaboo = manifest.files?.peekaboo; + const requiredPeekabooVersion = + String(process.env.EVAOS_REQUIRED_PEEKABOO_VERSION || '').trim() || DEFAULT_REQUIRED_PEEKABOO_VERSION; + if ( + peekaboo?.version !== requiredPeekabooVersion || + peekaboo?.license !== 'MIT' || + peekaboo?.license_path !== PEEKABOO_LICENSE_RELATIVE_PATH || + requireSha256(peekaboo?.license_sha256, 'Peekaboo license') !== + requireSha256( + manifest.files?.licenses?.find((license) => license?.path === PEEKABOO_LICENSE_RELATIVE_PATH)?.sha256, + 'recorded Peekaboo license' + ) + ) { + throw new Error( + 'Desktop Bridge payload Peekaboo version or license identity does not match the approved contract.' + ); + } + verifyPayloadFile(payloadDir, peekaboo, 'bin/peekaboo', 'Peekaboo executable', { arm64MachO: true, }); verifyPayloadFile(payloadDir, manifest.files?.connector_helper, 'bin/evaos-connector-helper', 'connector helper', { @@ -604,6 +622,15 @@ function preparePinnedBridgePayload(payloadDir, resourceDir, expectedPayloadSha2 producerManifestSha256: requireSha256(expectedManifestSha256, 'out-of-band manifest'), sourceCommit: validated.manifest.source.commit, sourceVersion: validated.manifest.source.version, + bundledTools: { + peekaboo: { + version: validated.manifest.files.peekaboo.version, + sourceSha256: validated.manifest.files.peekaboo.sha256, + license: validated.manifest.files.peekaboo.license, + licensePath: validated.manifest.files.peekaboo.license_path, + licenseSha256: validated.manifest.files.peekaboo.license_sha256, + }, + }, payload: { algorithm: copiedPayload.algorithm, sha256: copiedPayload.sha256, diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index b7998d642e..361302eb9e 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -113,6 +113,13 @@ describe('prepareEvaosDesktopBridgeResource', () => { sourceCommit, producerManifest: 'payload-manifest.json', producerManifestSha256: manifestSha256, + bundledTools: { + peekaboo: { + version: '3.8.0', + license: 'MIT', + licensePath: 'licenses/Peekaboo-LICENSE.txt', + }, + }, payload: { algorithm: 'sha256-tree-v1', sha256: payloadSha256, @@ -460,7 +467,7 @@ function writePinnedPayloadFixture( writeFileSync(join(payloadDir, 'evaos-desktop-bridge'), options.rootMachO === false ? '#!/bin/sh\nexit 0\n' : machO); chmodSync(join(payloadDir, 'evaos-desktop-bridge'), 0o755); writeFileSync(join(payloadDir, '_internal', 'runtime.dat'), 'private runtime\n'); - writeFileSync(join(payloadDir, 'licenses', 'LICENSE'), license); + writeFileSync(join(payloadDir, 'licenses', 'Peekaboo-LICENSE.txt'), license); const payload = bridgeResource.computePayloadTreeDigest(payloadDir); const sha256 = (relativePath: string) => @@ -490,12 +497,25 @@ function writePinnedPayloadFixture( }, files: { root_executable: { path: 'evaos-desktop-bridge', sha256: sha256('evaos-desktop-bridge') }, - peekaboo: { path: 'bin/peekaboo', sha256: sha256('bin/peekaboo') }, + peekaboo: { + path: 'bin/peekaboo', + sha256: sha256('bin/peekaboo'), + version: '3.8.0', + license: 'MIT', + license_path: 'licenses/Peekaboo-LICENSE.txt', + license_sha256: sha256('licenses/Peekaboo-LICENSE.txt'), + }, connector_helper: { path: 'bin/evaos-connector-helper', sha256: sha256('bin/evaos-connector-helper'), }, - licenses: [{ name: 'Desktop Bridge', path: 'licenses/LICENSE', sha256: sha256('licenses/LICENSE') }], + licenses: [ + { + name: 'Peekaboo', + path: 'licenses/Peekaboo-LICENSE.txt', + sha256: sha256('licenses/Peekaboo-LICENSE.txt'), + }, + ], }, payload: { algorithm: payload.algorithm, From 0a4f0d2c2064fdab3e6ff29e2b411e2f6fd6c206 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 17:39:19 +0700 Subject: [PATCH 5/6] fix(workbench): close desktop bridge release gates --- .github/workflows/_build-reusable.yml | 85 ++++++++++- .github/workflows/build-and-release.yml | 5 + .github/workflows/build-manual.yml | 5 + scripts/afterPack.js | 34 ++++- scripts/afterSign.js | 38 ++++- scripts/evaosBetaReleaseGate.js | 33 +++- scripts/prepareEvaosDesktopBridgeResource.js | 99 ++++++++++-- .../afterPackBundledResources.test.ts | 141 ++++++++++-------- .../unit/process/evaosBetaReleaseGate.test.ts | 47 +++++- .../prepareEvaosDesktopBridgeResource.test.ts | 105 +++++++++++-- 10 files changed, 490 insertions(+), 102 deletions(-) diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index 690635b6a0..3bffdc0734 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -35,6 +35,26 @@ on: description: 'AionCore managed resources bundle mode: full or no-acp' type: string default: full + desktop_bridge_payload_repository: + description: 'Repository containing the reviewed Desktop Bridge payload workflow run' + type: string + default: 'electricsheephq/evaos-desktop-bridge' + desktop_bridge_payload_run_id: + description: 'Exact successful Desktop Bridge workflow run id containing the reviewed payload artifact' + type: string + default: '' + desktop_bridge_payload_artifact_name: + description: 'Exact Desktop Bridge Actions artifact name' + type: string + default: 'evaos-desktop-bridge-macos-arm64' + desktop_bridge_payload_sha256: + description: 'Approved sha256-tree-v1 payload digest' + type: string + default: '' + desktop_bridge_manifest_sha256: + description: 'Approved raw payload-manifest.json SHA-256 digest' + type: string + default: '' env: BUN_INSTALL_REGISTRY: 'https://registry.npmjs.org/' @@ -625,6 +645,69 @@ jobs: rm -f /tmp/*.dmg 2>/dev/null || true echo "Cleanup complete" + - name: Preflight pinned Desktop Bridge payload inputs (macOS only) + if: startsWith(matrix.platform, 'macos') + shell: bash + env: + REPOSITORY: ${{ inputs.desktop_bridge_payload_repository }} + RUN_ID: ${{ inputs.desktop_bridge_payload_run_id }} + ARTIFACT_NAME: ${{ inputs.desktop_bridge_payload_artifact_name }} + PAYLOAD_SHA256: ${{ inputs.desktop_bridge_payload_sha256 }} + MANIFEST_SHA256: ${{ inputs.desktop_bridge_manifest_sha256 }} + run: | + set -euo pipefail + if [ -z "$REPOSITORY" ] || [ -z "$RUN_ID" ] || [ -z "$ARTIFACT_NAME" ]; then + echo "::error::macOS release builds require an exact Desktop Bridge repository, workflow run id, and artifact name." + exit 1 + fi + if [[ ! "$PAYLOAD_SHA256" =~ ^[0-9a-fA-F]{64}$ ]] || [[ ! "$MANIFEST_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "::error::macOS release builds require valid payload and manifest SHA-256 pins." + exit 1 + fi + if [ "${{ matrix.arch }}" != "arm64" ]; then + echo "::error::The current self-contained Desktop Bridge producer contract supports macOS arm64 only." + exit 1 + fi + + - name: Download pinned Desktop Bridge payload (macOS only) + if: startsWith(matrix.platform, 'macos') + uses: actions/download-artifact@v7 + with: + repository: ${{ inputs.desktop_bridge_payload_repository }} + run-id: ${{ inputs.desktop_bridge_payload_run_id }} + name: ${{ inputs.desktop_bridge_payload_artifact_name }} + path: ${{ runner.temp }}/evaos-desktop-bridge-payload + github-token: ${{ secrets.EVAOS_DESKTOP_BRIDGE_ACTIONS_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} + + - name: Verify pinned Desktop Bridge payload identity (macOS only) + if: startsWith(matrix.platform, 'macos') + shell: bash + env: + PAYLOAD_SHA256: ${{ inputs.desktop_bridge_payload_sha256 }} + MANIFEST_SHA256: ${{ inputs.desktop_bridge_manifest_sha256 }} + run: | + set -euo pipefail + DOWNLOAD_DIR="${RUNNER_TEMP}/evaos-desktop-bridge-payload" + MANIFEST_PATH="$DOWNLOAD_DIR/payload-manifest.json" + PAYLOAD_DIR="$DOWNLOAD_DIR" + if [ ! -f "$MANIFEST_PATH" ] || [ -L "$MANIFEST_PATH" ]; then + echo "::error::Desktop Bridge artifact must place one regular payload-manifest.json at its extraction root." + exit 1 + fi + if find "$PAYLOAD_DIR" -type l -print -quit | grep -q .; then + echo "::error::Desktop Bridge artifact must not contain symbolic links." + exit 1 + fi + printf '%s %s\n' "$MANIFEST_SHA256" "$MANIFEST_PATH" | shasum -a 256 -c - + PAYLOAD_SHA256_NORMALIZED="$(printf '%s' "$PAYLOAD_SHA256" | tr '[:upper:]' '[:lower:]')" + MANIFEST_SHA256_NORMALIZED="$(printf '%s' "$MANIFEST_SHA256" | tr '[:upper:]' '[:lower:]')" + { + echo "EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR=$PAYLOAD_DIR" + echo "EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256=$PAYLOAD_SHA256_NORMALIZED" + echo "EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256=$MANIFEST_SHA256_NORMALIZED" + } >> "$GITHUB_ENV" + echo "Pinned Desktop Bridge payload manifest identity verified." + - name: Install evaOS Mac-control helper (macOS only) if: startsWith(matrix.platform, 'macos') shell: bash @@ -769,8 +852,6 @@ 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 }} - name: Validate macOS app staple inside DMG if: startsWith(matrix.platform, 'macos') diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 154d7639ba..3b02a34942 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -193,6 +193,11 @@ jobs: skip_code_quality: true macos_dmg_finalization: ${{ inputs.macos_dmg_finalization || 'local' }} managed_resources_bundle: no-acp + desktop_bridge_payload_repository: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_REPOSITORY || 'electricsheephq/evaos-desktop-bridge' }} + desktop_bridge_payload_run_id: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_RUN_ID }} + desktop_bridge_payload_artifact_name: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_ARTIFACT_NAME || 'evaos-desktop-bridge-macos-arm64' }} + desktop_bridge_payload_sha256: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256 }} + desktop_bridge_manifest_sha256: ${{ vars.EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256 }} matrix: >- ${{ (inputs.release_target_platforms || vars.EVAOS_RELEASE_TARGET_PLATFORMS || 'macos-arm64') == 'macos-arm64' && '{"include":[{"platform":"macos-arm64","os":"macos-15","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"}]}' || (inputs.release_target_platforms || vars.EVAOS_RELEASE_TARGET_PLATFORMS || 'macos-arm64') == 'macos' && '{"include":[{"platform":"macos-arm64","os":"macos-15","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"},{"platform":"macos-x64","os":"macos-15","command":"node scripts/build-with-builder.js x64 --mac --x64","artifact-name":"macos-build-x64","arch":"x64"}]}' || (inputs.release_target_platforms || vars.EVAOS_RELEASE_TARGET_PLATFORMS || 'macos-arm64') == 'windows' && '{"include":[{"platform":"windows-x64","os":"windows-2022","command":"node scripts/build-with-builder.js x64 --win --x64","artifact-name":"windows-build-x64","arch":"x64"},{"platform":"windows-arm64","os":"windows-11-arm","command":"node scripts/build-with-builder.js arm64 --win --arm64","artifact-name":"windows-build-arm64","arch":"arm64"}]}' || '{"include":[{"platform":"macos-arm64","os":"macos-15","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"},{"platform":"macos-x64","os":"macos-15","command":"node scripts/build-with-builder.js x64 --mac --x64","artifact-name":"macos-build-x64","arch":"x64"},{"platform":"windows-x64","os":"windows-2022","command":"node scripts/build-with-builder.js x64 --win --x64","artifact-name":"windows-build-x64","arch":"x64"},{"platform":"windows-arm64","os":"windows-11-arm","command":"node scripts/build-with-builder.js arm64 --win --arm64","artifact-name":"windows-build-arm64","arch":"arm64"},{"platform":"linux-x64","os":"ubuntu-latest","command":"node scripts/build-with-builder.js x64 --linux --x64","artifact-name":"linux-build-x64","arch":"x64"},{"platform":"linux-arm64","os":"ubuntu-24.04-arm","command":"node scripts/build-with-builder.js arm64 --linux --arm64","artifact-name":"linux-build-arm64","arch":"arm64"}]}' }} secrets: inherit diff --git a/.github/workflows/build-manual.yml b/.github/workflows/build-manual.yml index da0b01a549..23ffb504db 100644 --- a/.github/workflows/build-manual.yml +++ b/.github/workflows/build-manual.yml @@ -97,6 +97,11 @@ jobs: skip_code_quality: ${{ inputs.skip_code_quality }} aioncore_run_id: ${{ inputs.aioncore_run_id }} managed_resources_bundle: ${{ inputs.managed_resources_bundle }} + desktop_bridge_payload_repository: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_REPOSITORY || 'electricsheephq/evaos-desktop-bridge' }} + desktop_bridge_payload_run_id: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_RUN_ID }} + desktop_bridge_payload_artifact_name: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_ARTIFACT_NAME || 'evaos-desktop-bridge-macos-arm64' }} + desktop_bridge_payload_sha256: ${{ vars.EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256 }} + desktop_bridge_manifest_sha256: ${{ vars.EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256 }} append_commit_hash: true upload_installers_only: true secrets: inherit diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 9527daeae9..e827eae216 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -158,7 +158,7 @@ function requireMachOExecutable(filePath, relativePath) { ); } -function requirePinnedBridgePayloadIdentity(resourcesDir, manifest) { +function requirePinnedBridgePayloadIdentity(resourcesDir, manifest, env = process.env, targetArch = 'arm64') { const bridgeDir = path.join(resourcesDir, 'Bridge'); const producerManifestPath = path.join(bridgeDir, 'payload-manifest.json'); const payloadSha256 = String(manifest?.payload?.sha256 || '') @@ -183,6 +183,26 @@ function requirePinnedBridgePayloadIdentity(resourcesDir, manifest) { if (!validIdentity) { throw new Error('Release macOS bridge resource is missing its pinned immutable payload identity.'); } + const configuredPayloadSha256 = String(env.EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256 || '') + .trim() + .toLowerCase(); + const configuredManifestSha256 = String(env.EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256 || '') + .trim() + .toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(configuredPayloadSha256) || !/^[0-9a-f]{64}$/.test(configuredManifestSha256)) { + throw new Error('Release macOS bridge resource requires configured payload and producer manifest digests.'); + } + if (payloadSha256 !== configuredPayloadSha256) { + throw new Error('Release macOS bridge resource does not match its configured payload digest.'); + } + if (manifest.producerManifestSha256 !== configuredManifestSha256) { + throw new Error('Release macOS bridge resource does not match its configured producer manifest digest.'); + } + if (manifest.payload.target.architecture !== targetArch) { + throw new Error( + `Release macOS bridge payload target architecture ${manifest.payload.target.architecture} does not match Workbench target ${targetArch}.` + ); + } const producerManifestSha256 = crypto .createHash('sha256') .update(fs.readFileSync(producerManifestPath)) @@ -218,7 +238,7 @@ function requireManagedNodeRuntime(resourcesDir, runtimeKey, electronPlatformNam } } -function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { +function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, env = process.env, targetArch = 'arm64') { if (electronPlatformName !== 'darwin') return; const bridgePath = path.join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'); @@ -262,14 +282,14 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const strictReleaseBridge = - isTruthy(process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL) || - isTruthy(process.env.EVAOS_BETA_PUBLIC_RELEASE) || - isTruthy(process.env.EVAOS_BETA_REQUIRE_SIGNING); + isTruthy(env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL) || + isTruthy(env.EVAOS_BETA_PUBLIC_RELEASE) || + isTruthy(env.EVAOS_BETA_REQUIRE_SIGNING); if (strictReleaseBridge && manifest.placeholder === true) { throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { - requirePinnedBridgePayloadIdentity(resourcesDir, manifest); + requirePinnedBridgePayloadIdentity(resourcesDir, manifest, env, targetArch); requireMachOExecutable(bridgePath, path.join('Bridge', 'evaos-desktop-bridge')); requireMachOExecutable(peekabooPath, path.join('Bridge', 'bin', 'peekaboo')); requireMachOExecutable(helperPath, path.join('Bridge', 'bin', 'evaos-connector-helper')); @@ -344,7 +364,7 @@ module.exports = async function afterPack(context) { } verifyBundledResources(resourcesDir, electronPlatformName, targetArch); - verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName); + verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, process.env, targetArch); } else { throw new Error(`resources directory not found: ${resourcesDir}`); } diff --git a/scripts/afterSign.js b/scripts/afterSign.js index 54ec7b5041..b17b49a46a 100644 --- a/scripts/afterSign.js +++ b/scripts/afterSign.js @@ -160,10 +160,9 @@ function getAppTrustProcessTimeoutMs(env = process.env) { return getPositiveProcessTimeoutMs(env, 'EVAOS_APP_TRUST_PROCESS_TIMEOUT_MS', DEFAULT_APP_TRUST_PROCESS_TIMEOUT_MS); } -function isMachOExecutable(filePath) { +function isMachOFile(filePath) { if (!fs.existsSync(filePath)) return false; try { - fs.accessSync(filePath, fs.constants.X_OK); const header = fs.readFileSync(filePath, { encoding: null, flag: 'r' }).subarray(0, 4).toString('hex'); return MACHO_MAGICS.has(header); } catch { @@ -171,6 +170,15 @@ function isMachOExecutable(filePath) { } } +function isMachOExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return isMachOFile(filePath); + } catch { + return false; + } +} + function expectedMacControlHelperTeamId(env = process.env) { return ( getEnvValue(env, { @@ -201,7 +209,7 @@ function assertMacControlHelperSignature(filePath, env = process.env, runProcess if (!fs.existsSync(filePath)) { throw new Error(`Strict evaOS beta release is missing bundled Mac-control helper: ${filePath}`); } - if (!isMachOExecutable(filePath)) { + if (!isMachOFile(filePath)) { throw new Error( `Strict evaOS beta release requires bundled Mac-control helper to be a native Mach-O executable: ${filePath}` ); @@ -221,8 +229,30 @@ function assertMacControlHelperSignature(filePath, env = process.env, runProcess } function assertMacControlHelperSignatures(appPath, env = process.env, runProcess = spawnSync) { + const bridgeDir = path.join(appPath, 'Contents', 'Resources', 'Bridge'); + const verified = new Set(); for (const relativePath of MAC_CONTROL_HELPER_RELATIVE_PATHS) { - assertMacControlHelperSignature(path.join(appPath, relativePath), env, runProcess); + const filePath = path.join(appPath, relativePath); + assertMacControlHelperSignature(filePath, env, runProcess); + verified.add(filePath); + } + + const pendingDirs = [bridgeDir]; + const nestedMachOFiles = []; + while (pendingDirs.length > 0) { + const currentDir = pendingDirs.pop(); + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const filePath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + pendingDirs.push(filePath); + } else if (entry.isFile() && isMachOFile(filePath) && !verified.has(filePath)) { + nestedMachOFiles.push(filePath); + } + } + } + nestedMachOFiles.sort((left, right) => left.localeCompare(right)); + for (const filePath of nestedMachOFiles) { + assertMacControlHelperSignature(filePath, env, runProcess); } } diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 5211fe2832..98cb142300 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -1527,6 +1527,7 @@ function inspectMacosZipBridgePayload(zipPath) { 'import hashlib', 'import json', 'import pathlib', + 'import re', 'import sys', 'import zipfile', 'path = pathlib.Path(sys.argv[1])', @@ -1540,28 +1541,49 @@ function inspectMacosZipBridgePayload(zipPath) { ' if part.endswith(".app") and parts[index + 1:index + 4] == ["Contents", "Resources", "Bridge"]:', ' return "/".join(parts[index + 4:])', ' return None', - 'result = {"hasBridgeExecutable": False, "bridgeExecutableMachO": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', + 'result = {"hasBridgeExecutable": False, "bridgeExecutableMachO": False, "hasBridgeManifest": False, "hasProducerManifest": False, "hasPrivateRuntime": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestV2Valid": False, "producerManifestValid": False, "producerManifestDigestValid": False, "payloadMetadataValid": False, "payloadFileCountValid": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', 'entries = {}', + 'payload_file_count = 0', 'with zipfile.ZipFile(path) as archive:', ' for name in archive.namelist():', ' suffix = bridge_entry_suffix(name)', - ' if suffix in {"evaos-desktop-bridge", "manifest.json", "bin/peekaboo", "bin/evaos-connector-helper", expected_license_path}:', + ' if suffix is not None and suffix not in {"manifest.json", "payload-manifest.json"} and not name.endswith("/"):', + ' payload_file_count += 1', + ' if suffix is not None and suffix.startswith("_internal/") and not name.endswith("/"):', + ' result["hasPrivateRuntime"] = True', + ' if suffix in {"evaos-desktop-bridge", "manifest.json", "payload-manifest.json", "bin/peekaboo", "bin/evaos-connector-helper", expected_license_path}:', ' entries[suffix] = name', ' result["hasBridgeExecutable"] = "evaos-desktop-bridge" in entries', ' result["hasBridgeManifest"] = "manifest.json" in entries', + ' result["hasProducerManifest"] = "payload-manifest.json" in entries', ' result["hasPeekaboo"] = "bin/peekaboo" in entries', ' result["hasConnectorHelper"] = "bin/evaos-connector-helper" in entries', ' result["hasPeekabooLicense"] = expected_license_path in entries', ' if result["hasBridgeExecutable"]:', ' result["bridgeExecutableMachO"] = archive.read(entries["evaos-desktop-bridge"], pwd=None)[:4].hex() in macho_magics', ' manifest = {}', + ' producer_manifest = {}', + ' producer_manifest_bytes = b""', ' if result["hasBridgeManifest"]:', ' try:', ' manifest = json.loads(archive.read(entries["manifest.json"]))', ' except (json.JSONDecodeError, UnicodeDecodeError):', ' manifest = {}', + ' if result["hasProducerManifest"]:', + ' producer_manifest_bytes = archive.read(entries["payload-manifest.json"])', + ' try:', + ' producer_manifest = json.loads(producer_manifest_bytes)', + ' except (json.JSONDecodeError, UnicodeDecodeError):', + ' producer_manifest = {}', ' peekaboo = manifest.get("bundledTools", {}).get("peekaboo", {}) if isinstance(manifest, dict) else {}', ' result["manifestPlaceholderFalse"] = manifest.get("placeholder") is False if isinstance(manifest, dict) else False', + ' payload = manifest.get("payload", {}) if isinstance(manifest, dict) else {}', + ' payload_sha256 = payload.get("sha256", "") if isinstance(payload, dict) else ""', + ' result["manifestV2Valid"] = manifest.get("schema") == "evaos-desktop-bridge-resource/v2" and manifest.get("producerManifest") == "payload-manifest.json" and re.fullmatch(r"[0-9a-f]{40}", str(manifest.get("sourceCommit", ""))) is not None', + ' result["producerManifestValid"] = producer_manifest.get("schema_version") == 1 and producer_manifest.get("target") == {"platform": "macos", "architecture": "arm64"} if isinstance(producer_manifest, dict) else False', + ' result["producerManifestDigestValid"] = bool(producer_manifest_bytes) and hashlib.sha256(producer_manifest_bytes).hexdigest() == manifest.get("producerManifestSha256")', + ' result["payloadMetadataValid"] = payload.get("algorithm") == "sha256-tree-v1" and re.fullmatch(r"[0-9a-f]{64}", str(payload_sha256)) is not None and payload.get("target") == {"platform": "macos", "architecture": "arm64"} and isinstance(payload.get("fileCount"), int)', + ' result["payloadFileCountValid"] = payload.get("fileCount") == payload_file_count', ' result["manifestSourceDigestValid"] = peekaboo.get("version") == expected_version and peekaboo.get("sourceSha256") == expected_source_sha256', ' result["manifestLicenseMetadataValid"] = peekaboo.get("license") == "MIT" and peekaboo.get("licensePath") == expected_license_path', ' if result["hasPeekaboo"]:', @@ -1612,6 +1634,13 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { assertZipBridgeProbe(probe, 'hasBridgeExecutable', zipName, 'executable'); assertZipBridgeProbe(probe, 'bridgeExecutableMachO', zipName, 'executable Mach-O shape'); assertZipBridgeProbe(probe, 'hasBridgeManifest', zipName, 'manifest'); + assertZipBridgeProbe(probe, 'manifestV2Valid', zipName, 'v2 payload manifest'); + assertZipBridgeProbe(probe, 'hasProducerManifest', zipName, 'producer manifest'); + assertZipBridgeProbe(probe, 'producerManifestValid', zipName, 'producer manifest identity'); + assertZipBridgeProbe(probe, 'producerManifestDigestValid', zipName, 'producer manifest digest'); + assertZipBridgeProbe(probe, 'payloadMetadataValid', zipName, 'payload metadata'); + assertZipBridgeProbe(probe, 'payloadFileCountValid', zipName, 'payload file count'); + assertZipBridgeProbe(probe, 'hasPrivateRuntime', zipName, 'private runtime'); assertZipBridgeProbe(probe, 'hasPeekaboo', zipName, 'Peekaboo binary'); assertZipBridgeProbe(probe, 'peekabooMachO', zipName, 'Peekaboo binary Mach-O shape'); assertZipBridgeProbe(probe, 'hasConnectorHelper', zipName, 'connector helper'); diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index 0d64effd76..4ac7b3ad04 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -222,6 +222,15 @@ function isMachOExecutable(filePath) { if (!filePath || !fs.existsSync(filePath)) return false; try { fs.accessSync(filePath, fs.constants.X_OK); + return isMachOFile(filePath); + } catch { + return false; + } +} + +function isMachOFile(filePath) { + if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false; + try { const header = fs.readFileSync(filePath, { encoding: null, flag: 'r' }).subarray(0, 4).toString('hex'); return MACHO_MAGICS.has(header); } catch { @@ -458,6 +467,15 @@ function requireSha256(value, description) { return digest; } +function isExactVersionIdentity(value) { + const version = String(value || '').trim(); + return ( + Boolean(version) && + !/[<>=~*^/\\]/.test(version) && + !/^(latest|main|master|head|dev|development|local)$/i.test(version) + ); +} + function resolvePayloadFile(payloadDir, relativePath, description) { const normalized = payloadRelativePath(String(relativePath || '').trim()); if (!normalized || path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) { @@ -475,8 +493,8 @@ function resolvePayloadFile(payloadDir, relativePath, description) { return { absolutePath, relativePath: normalized }; } -function isArm64MachOExecutable(filePath) { - if (!isMachOExecutable(filePath)) return false; +function isArm64MachOFile(filePath) { + if (!isMachOFile(filePath)) return false; const header = fs.readFileSync(filePath).subarray(0, 12); if (header.length < 12) return false; const magic = header.subarray(0, 4).toString('hex'); @@ -485,6 +503,15 @@ function isArm64MachOExecutable(filePath) { return false; } +function isArm64MachOExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return isArm64MachOFile(filePath); + } catch { + return false; + } +} + function verifyPayloadFile(payloadDir, entry, expectedPath, description, options = {}) { if (!entry || typeof entry !== 'object') { throw new Error(`Desktop Bridge payload manifest is missing ${description}.`); @@ -542,13 +569,25 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected throw new Error('Desktop Bridge payload source identity is incomplete or mutable.'); } if ( - !String(manifest.toolchain?.python || '').trim() || + !isExactVersionIdentity(manifest.toolchain?.python) || !String(manifest.toolchain?.freezer?.name || '').trim() || - !String(manifest.toolchain?.freezer?.version || '').trim() || - !Array.isArray(manifest.toolchain?.dependencies) + !isExactVersionIdentity(manifest.toolchain?.freezer?.version) ) { throw new Error('Desktop Bridge payload toolchain identity is incomplete.'); } + const dependencies = manifest.toolchain?.dependencies; + if ( + !Array.isArray(dependencies) || + dependencies.length === 0 || + dependencies.some( + (dependency) => + !String(dependency?.name || '').trim() || + !isExactVersionIdentity(dependency?.version) || + !/^[0-9a-f]{64}$/i.test(String(dependency?.sha256 || '')) + ) + ) { + throw new Error('Desktop Bridge payload toolchain dependency lock is incomplete or mutable.'); + } const expectedSha256 = requireSha256(expectedPayloadSha256, 'out-of-band payload'); const recordedSha256 = requireSha256(manifest.payload?.sha256, 'manifest payload'); @@ -590,17 +629,57 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected throw new Error('Desktop Bridge payload must include at least one recorded license.'); } for (const [index, license] of manifest.files.licenses.entries()) { - if (!String(license?.name || '').trim() || !String(license?.path || '').startsWith('licenses/')) { + if ( + !String(license?.component || '').trim() || + !isExactVersionIdentity(license?.version) || + !String(license?.license || '').trim() || + !String(license?.name || '').trim() || + !String(license?.path || '').startsWith('licenses/') + ) { throw new Error(`Desktop Bridge payload license ${index + 1} has invalid identity.`); } verifyPayloadFile(payloadDir, license, undefined, `license ${index + 1}`); } - const signingInputs = Array.isArray(manifest.signing?.inputs) ? manifest.signing.inputs : []; - for (const requiredInput of ['evaos-desktop-bridge', 'bin/peekaboo', 'bin/evaos-connector-helper']) { - if (manifest.signing?.state !== 'unsigned' || !signingInputs.includes(requiredInput)) { - throw new Error('Desktop Bridge payload signing inputs are incomplete.'); + const requiredLicenseComponents = ['CPython', 'PyInstaller', 'PyObjC', 'evaos-desktop-bridge', 'Peekaboo']; + const licenseComponents = Array.isArray(manifest.toolchain?.license_components) + ? manifest.toolchain.license_components + : []; + for (const component of requiredLicenseComponents) { + const componentIdentity = licenseComponents.find((entry) => entry?.component === component); + const licenseFile = manifest.files.licenses.find((entry) => entry?.component === component); + if ( + !componentIdentity || + !licenseFile || + !isExactVersionIdentity(componentIdentity.version) || + !String(componentIdentity.license || '').trim() || + !String(componentIdentity.path || '').startsWith('licenses/') || + componentIdentity.version !== licenseFile.version || + componentIdentity.license !== licenseFile.license || + componentIdentity.path !== licenseFile.path + ) { + throw new Error(`Desktop Bridge payload license coverage is incomplete for ${component}.`); } } + const machOFiles = listPayloadFiles(payloadDir) + .filter(({ absolutePath }) => isMachOFile(absolutePath)) + .map(({ absolutePath, relativePath }) => { + if (!isArm64MachOFile(absolutePath)) { + throw new Error(`Desktop Bridge payload contains a non-arm64 Mach-O file: ${relativePath}`); + } + return relativePath; + }) + .toSorted((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))); + const signingInputs = Array.isArray(manifest.signing?.inputs) ? [...manifest.signing.inputs] : []; + signingInputs.sort((left, right) => + Buffer.compare(Buffer.from(String(left), 'utf8'), Buffer.from(String(right), 'utf8')) + ); + if ( + manifest.signing?.state !== 'unsigned' || + signingInputs.length !== machOFiles.length || + signingInputs.some((input, index) => input !== machOFiles[index]) + ) { + throw new Error('Desktop Bridge payload signing inputs do not match the complete Mach-O closure.'); + } return { manifest, payload: actualPayload }; } diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 2b8b00ad7b..f4dcef7d69 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -9,7 +9,12 @@ const require = createRequire(import.meta.url); const afterPack = require('../../../scripts/afterPack.js') as { isMachOExecutable: (filePath: string) => boolean; verifyBundledResources: (resourcesDir: string, electronPlatformName: string, targetArch: string) => void; - verifyEvaosDesktopBridgeResource: (resourcesDir: string, electronPlatformName: string) => void; + verifyEvaosDesktopBridgeResource: ( + resourcesDir: string, + electronPlatformName: string, + env?: Record, + targetArch?: string + ) => void; }; const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { computePayloadTreeDigest: (payloadDir: string) => { algorithm: string; sha256: string; fileCount: number }; @@ -56,7 +61,7 @@ function writeBridgeFixture( payloadSha256?: string; producerManifestSha256?: string; } = {} -): void { +): { manifestSha256?: string; payloadSha256?: string } { const bridgeDir = join(resourcesDir, 'Bridge'); mkdirSync(join(bridgeDir, 'bin'), { recursive: true }); const bridgePath = join(bridgeDir, 'evaos-desktop-bridge'); @@ -83,26 +88,30 @@ function writeBridgeFixture( const producerManifest = '{"schema_version":1}\n'; writeFileSync(join(bridgeDir, 'payload-manifest.json'), producerManifest); const payload = bridgeResource.computePayloadTreeDigest(bridgeDir); + const manifestSha256 = + options.producerManifestSha256 || createHash('sha256').update(producerManifest).digest('hex'); + const payloadSha256 = options.payloadSha256 || payload.sha256; writeFileSync( join(bridgeDir, 'manifest.json'), `${JSON.stringify({ schema: 'evaos-desktop-bridge-resource/v2', placeholder: false, producerManifest: 'payload-manifest.json', - producerManifestSha256: - options.producerManifestSha256 || createHash('sha256').update(producerManifest).digest('hex'), + producerManifestSha256: manifestSha256, sourceCommit: '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b', payload: { algorithm: payload.algorithm, - sha256: options.payloadSha256 || payload.sha256, + sha256: payloadSha256, fileCount: payload.fileCount, target: { platform: 'macos', architecture: 'arm64' }, }, })}\n` ); + return { manifestSha256, payloadSha256 }; } else { writeFileSync(join(bridgeDir, 'manifest.json'), '{"placeholder":false}\n'); } + return {}; } afterEach(() => { @@ -210,44 +219,29 @@ describe('afterPack bundled resource verification', () => { }); it('rejects script control helper resources for release-mode macOS builds', () => { - const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { helper: true, nativeRoot: true, pinnedPayload: true }); + const identity = writeBridgeFixture(resourcesDir, { helper: true, nativeRoot: true, pinnedPayload: true }); - try { - process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( - /native Mach-O executable/ - ); - } finally { - restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); - } + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity))).toThrow( + /native Mach-O executable/ + ); }); it('rejects a script desktop bridge for release-mode macOS builds', () => { - const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, pinnedPayload: true }); - - try { - process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - - expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'))).toBe(false); - expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'peekaboo'))).toBe(true); - expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'evaos-connector-helper'))).toBe(true); - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( - /Bridge\/evaos-desktop-bridge/ - ); - } finally { - restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); - } + const identity = writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, pinnedPayload: true }); + + expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'evaos-desktop-bridge'))).toBe(false); + expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'peekaboo'))).toBe(true); + expect(afterPack.isMachOExecutable(join(resourcesDir, 'Bridge', 'bin', 'evaos-connector-helper'))).toBe(true); + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity))).toThrow( + /Bridge\/evaos-desktop-bridge/ + ); }); it('rejects a release payload whose immutable tree digest is tampered', () => { - const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { + const identity = writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, nativeRoot: true, @@ -255,20 +249,14 @@ describe('afterPack bundled resource verification', () => { payloadSha256: '0'.repeat(64), }); - try { - process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( - /immutable payload identity/ - ); - } finally { - restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); - } + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity))).toThrow( + /immutable payload identity/ + ); }); it('rejects a release payload whose producer manifest digest is tampered', () => { - const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { + const identity = writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, nativeRoot: true, @@ -276,32 +264,55 @@ describe('afterPack bundled resource verification', () => { producerManifestSha256: '0'.repeat(64), }); - try { - process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).toThrow( - /producer manifest digest/ - ); - } finally { - restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); - } + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity))).toThrow( + /producer manifest digest/ + ); + }); + + it('rejects a release payload that differs from the configured out-of-band pin', () => { + const resourcesDir = makeTempResources(); + const identity = writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + }); + + expect(() => + afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', { + EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL: '1', + EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256: '0'.repeat(64), + EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256: identity.manifestSha256, + }) + ).toThrow(/configured payload digest/); }); it('accepts a pinned native desktop bridge payload for release-mode macOS builds', () => { - const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); - writeBridgeFixture(resourcesDir, { + const identity = writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true, nativeRoot: true, pinnedPayload: true, }); - try { - process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; - expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin')).not.toThrow(); - } finally { - restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); - } + expect(() => + afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity)) + ).not.toThrow(); + }); + + it('rejects an arm64 Desktop Bridge payload in an x64 Workbench package', () => { + const resourcesDir = makeTempResources(); + const identity = writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + }); + + expect(() => + afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity), 'x64') + ).toThrow(/target architecture arm64.*Workbench target x64/); }); it('requires the evaOS connector binary in macOS bridge resources', () => { @@ -313,10 +324,10 @@ describe('afterPack bundled resource verification', () => { }); }); -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - return; - } - process.env[name] = value; +function strictBridgeEnv(identity: { manifestSha256?: string; payloadSha256?: string }) { + return { + EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL: '1', + EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256: identity.payloadSha256, + EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256: identity.manifestSha256, + }; } diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index aab0755410..c502cf1f0b 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -127,6 +127,8 @@ function writeMacosBridgeZip( extraEntryCount?: number; omitPeekaboo?: boolean; omitLicense?: boolean; + omitInternalRuntime?: boolean; + legacyManifest?: boolean; scriptBridge?: boolean; sourceSha256?: string; manifestLicenseSha256?: string; @@ -145,10 +147,17 @@ function writeMacosBridgeZip( 'source_sha256 = sys.argv[5]', 'manifest_license_sha256 = sys.argv[6]', 'script_bridge = sys.argv[7] == "1"', + 'omit_internal_runtime = sys.argv[8] == "1"', + 'legacy_manifest = sys.argv[9] == "1"', 'app_root = zip_path.stem.replace("-mac-arm64", "").replace("-mac-x64", "") + ".app"', 'license_bytes = b"MIT License\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\n"', 'license_sha256 = manifest_license_sha256 or hashlib.sha256(license_bytes).hexdigest()', - 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}}}', + 'peekaboo = {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}', + 'producer_manifest = {"schema_version": 1, "target": {"platform": "macos", "architecture": "arm64"}, "source": {"repository": "electricsheephq/evaos-desktop-bridge", "commit": "60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b", "version": "0.7.0"}}', + 'producer_manifest_bytes = (json.dumps(producer_manifest, sort_keys=True) + "\\n").encode()', + 'producer_manifest_sha256 = hashlib.sha256(producer_manifest_bytes).hexdigest()', + 'payload_file_count = 5 - int(omit_peekaboo) - int(omit_license) - int(omit_internal_runtime)', + 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": peekaboo}} if legacy_manifest else {"schema": "evaos-desktop-bridge-resource/v2", "placeholder": False, "producerManifest": "payload-manifest.json", "producerManifestSha256": producer_manifest_sha256, "sourceCommit": "60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b", "payload": {"algorithm": "sha256-tree-v1", "sha256": "1" * 64, "fileCount": payload_file_count, "target": {"platform": "macos", "architecture": "arm64"}}, "bundledTools": {"peekaboo": peekaboo}}', 'with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:', ' bridge_bytes = b"#!/usr/bin/env bash\\n" if script_bridge else bytes.fromhex("cffaedfe0c00000100000000")', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/evaos-desktop-bridge", bridge_bytes)', @@ -157,6 +166,10 @@ function writeMacosBridgeZip( ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"))', ' if not omit_license:', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/licenses/Peekaboo-LICENSE.txt", license_bytes)', + ' if not omit_internal_runtime:', + ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/_internal/runtime.dat", b"private runtime")', + ' if not legacy_manifest:', + ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/payload-manifest.json", producer_manifest_bytes)', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/manifest.json", json.dumps(manifest) + "\\n")', ' for index in range(extra_entry_count):', ' archive.writestr(f"{app_root}/Contents/Resources/noise/entry-{index:05d}.txt", "x\\n")', @@ -171,6 +184,8 @@ function writeMacosBridgeZip( options.sourceSha256 || '4a5c7e28c263c84e406aa1853ef62cad3042b13f40a7a9e044ec74ec42933383', options.manifestLicenseSha256 || '', options.scriptBridge ? '1' : '0', + options.omitInternalRuntime ? '1' : '0', + options.legacyManifest ? '1' : '0', ]); } @@ -439,6 +454,22 @@ describe('evaOS beta release gate', () => { expect(workflow).toContain('EVAOS_PEEKABOO_LICENSE=$PEEKABOO_LICENSE'); }); + it('requires macOS release builds to download a doubly pinned Desktop Bridge payload artifact', () => { + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/_build-reusable.yml'), 'utf8'); + + expect(workflow).toContain('desktop_bridge_payload_run_id:'); + expect(workflow).toContain('desktop_bridge_payload_artifact_name:'); + expect(workflow).toContain('uses: actions/download-artifact@v7'); + expect(workflow).toContain('repository: ${{ inputs.desktop_bridge_payload_repository }}'); + expect(workflow).toContain('run-id: ${{ inputs.desktop_bridge_payload_run_id }}'); + expect(workflow).toContain('github-token: ${{ secrets.EVAOS_DESKTOP_BRIDGE_ACTIONS_TOKEN'); + expect(workflow).toContain('MANIFEST_PATH="$DOWNLOAD_DIR/payload-manifest.json"'); + expect(workflow).toContain('PAYLOAD_DIR="$DOWNLOAD_DIR"'); + expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR='); + expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256='); + expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256='); + }); + it('requires functional smoke to verify the packaged Peekaboo version', () => { const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/workbench-functional-smoke.yml'), 'utf8'); @@ -480,6 +511,7 @@ describe('evaOS beta release gate', () => { const appPath = path.join(dir, 'evaOS Workbench.app'); const helperDir = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'bin'); const bridgePath = path.join(appPath, 'Contents', 'Resources', 'Bridge', 'evaos-desktop-bridge'); + const nestedRuntimePath = path.join(appPath, 'Contents', 'Resources', 'Bridge', '_internal', 'libpython.dylib'); const peekabooPath = path.join(helperDir, 'peekaboo'); const connectorHelperPath = path.join(helperDir, 'evaos-connector-helper'); const inspectedPaths: string[] = []; @@ -498,6 +530,7 @@ describe('evaOS beta release gate', () => { try { writeMachOFixture(bridgePath); + writeMachOFixture(nestedRuntimePath); writeMachOFixture(peekabooPath); writeMachOFixture(connectorHelperPath); @@ -514,7 +547,7 @@ describe('evaOS beta release gate', () => { } ) ).not.toThrow(); - expect(inspectedPaths).toEqual([bridgePath, peekabooPath, connectorHelperPath]); + expect(inspectedPaths).toEqual([bridgePath, peekabooPath, connectorHelperPath, nestedRuntimePath]); expect(() => afterSign.assertMacControlHelperSignatures( @@ -1442,6 +1475,16 @@ describe('evaOS beta release gate', () => { it('rejects macOS release ZIPs without exact Peekaboo package proof', () => { const cases = [ + { + name: 'legacy bridge manifest', + options: { legacyManifest: true }, + expected: /v2 payload manifest/, + }, + { + name: 'missing private runtime', + options: { omitInternalRuntime: true }, + expected: /private runtime/, + }, { name: 'script root bridge', options: { scriptBridge: true }, diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index 361302eb9e..17f1380bd7 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -197,6 +197,46 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('requires every nested Mach-O file to appear in signing inputs', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-signing-closure-')); + try { + const payloadDir = join(dir, 'payload'); + const { payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.signing.inputs = manifest.signing.inputs.filter( + (relativePath: string) => relativePath !== '_internal/libpython.dylib' + ); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const manifestSha256 = createHash('sha256').update(readFileSync(manifestPath)).digest('hex'); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/signing inputs.*Mach-O closure/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects an incomplete or unhashed toolchain dependency lock', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-toolchain-lock-')); + try { + const payloadDir = join(dir, 'payload'); + const { payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.toolchain.dependencies = []; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const manifestSha256 = createHash('sha256').update(readFileSync(manifestPath)).digest('hex'); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/toolchain dependency lock/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + it('refuses the legacy host-Python wrapper path for strict release preparation', () => { expect(() => bridgeResource.resolvePinnedBridgeConfiguration( @@ -467,7 +507,18 @@ function writePinnedPayloadFixture( writeFileSync(join(payloadDir, 'evaos-desktop-bridge'), options.rootMachO === false ? '#!/bin/sh\nexit 0\n' : machO); chmodSync(join(payloadDir, 'evaos-desktop-bridge'), 0o755); writeFileSync(join(payloadDir, '_internal', 'runtime.dat'), 'private runtime\n'); - writeFileSync(join(payloadDir, 'licenses', 'Peekaboo-LICENSE.txt'), license); + writeFileSync(join(payloadDir, '_internal', 'libpython.dylib'), machO); + chmodSync(join(payloadDir, '_internal', 'libpython.dylib'), 0o644); + const licenseFiles = { + CPython: 'licenses/CPython-LICENSE.txt', + PyInstaller: 'licenses/PyInstaller-LICENSE.txt', + PyObjC: 'licenses/PyObjC-LICENSE.txt', + 'evaos-desktop-bridge': 'licenses/evaos-desktop-bridge-LICENSE.txt', + Peekaboo: 'licenses/Peekaboo-LICENSE.txt', + }; + for (const licensePath of Object.values(licenseFiles)) { + writeFileSync(join(payloadDir, licensePath), license); + } const payload = bridgeResource.computePayloadTreeDigest(payloadDir); const sha256 = (relativePath: string) => @@ -493,7 +544,24 @@ function writePinnedPayloadFixture( toolchain: { python: '3.12.12', freezer: { name: 'pyinstaller', version: '6.16.0' }, - dependencies: [{ name: 'pyobjc-core', version: '11.1' }], + dependencies: [{ name: 'pyobjc-core', version: '11.1', sha256: 'a'.repeat(64) }], + license_components: [ + { component: 'CPython', version: '3.12.12', license: 'PSF-2.0', path: licenseFiles.CPython }, + { + component: 'PyInstaller', + version: '6.16.0', + license: 'GPL-2.0-or-later-with-bootloader-exception', + path: licenseFiles.PyInstaller, + }, + { component: 'PyObjC', version: '11.1', license: 'MIT', path: licenseFiles.PyObjC }, + { + component: 'evaos-desktop-bridge', + version: '0.7.0', + license: 'Proprietary', + path: licenseFiles['evaos-desktop-bridge'], + }, + { component: 'Peekaboo', version: '3.8.0', license: 'MIT', path: licenseFiles.Peekaboo }, + ], }, files: { root_executable: { path: 'evaos-desktop-bridge', sha256: sha256('evaos-desktop-bridge') }, @@ -509,13 +577,30 @@ function writePinnedPayloadFixture( path: 'bin/evaos-connector-helper', sha256: sha256('bin/evaos-connector-helper'), }, - licenses: [ - { - name: 'Peekaboo', - path: 'licenses/Peekaboo-LICENSE.txt', - sha256: sha256('licenses/Peekaboo-LICENSE.txt'), - }, - ], + licenses: Object.entries(licenseFiles).map(([component, licensePath]) => ({ + component, + version: + component === 'CPython' + ? '3.12.12' + : component === 'PyInstaller' + ? '6.16.0' + : component === 'PyObjC' + ? '11.1' + : component === 'Peekaboo' + ? '3.8.0' + : '0.7.0', + license: + component === 'CPython' + ? 'PSF-2.0' + : component === 'PyInstaller' + ? 'GPL-2.0-or-later-with-bootloader-exception' + : component === 'evaos-desktop-bridge' + ? 'Proprietary' + : 'MIT', + name: `${component} license`, + path: licensePath, + sha256: sha256(licensePath), + })), }, payload: { algorithm: payload.algorithm, @@ -524,7 +609,7 @@ function writePinnedPayloadFixture( }, signing: { state: 'unsigned', - inputs: ['evaos-desktop-bridge', 'bin/peekaboo', 'bin/evaos-connector-helper'], + inputs: ['_internal/libpython.dylib', 'bin/evaos-connector-helper', 'bin/peekaboo', 'evaos-desktop-bridge'], }, }, null, From 2708c321aa7ff8998ed3f5416af42ee38d6c9ca3 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 12 Jul 2026 18:06:05 +0700 Subject: [PATCH 6/6] fix(workbench): harden frozen bridge release proof --- .github/workflows/_build-reusable.yml | 71 ++++++++++++- ...2-workbench-fresh-mac-zero-touch-design.md | 22 ++-- scripts/afterPack.js | 18 ++++ scripts/evaosBetaReleaseGate.js | 46 +++++++- scripts/prepareEvaosDesktopBridgeResource.js | 74 ++++++++++++- .../afterPackBundledResources.test.ts | 31 +++++- .../unit/process/evaosBetaReleaseGate.test.ts | 45 +++++++- .../prepareEvaosDesktopBridgeResource.test.ts | 100 +++++++++++++++++- 8 files changed, 375 insertions(+), 32 deletions(-) diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index 3bffdc0734..69401c9215 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -676,7 +676,7 @@ jobs: repository: ${{ inputs.desktop_bridge_payload_repository }} run-id: ${{ inputs.desktop_bridge_payload_run_id }} name: ${{ inputs.desktop_bridge_payload_artifact_name }} - path: ${{ runner.temp }}/evaos-desktop-bridge-payload + path: ${{ runner.temp }}/evaos-desktop-bridge-artifact github-token: ${{ secrets.EVAOS_DESKTOP_BRIDGE_ACTIONS_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} - name: Verify pinned Desktop Bridge payload identity (macOS only) @@ -687,11 +687,59 @@ jobs: MANIFEST_SHA256: ${{ inputs.desktop_bridge_manifest_sha256 }} run: | set -euo pipefail - DOWNLOAD_DIR="${RUNNER_TEMP}/evaos-desktop-bridge-payload" - MANIFEST_PATH="$DOWNLOAD_DIR/payload-manifest.json" - PAYLOAD_DIR="$DOWNLOAD_DIR" + ARTIFACT_DIR="${RUNNER_TEMP}/evaos-desktop-bridge-artifact" + ARCHIVE_PATH="$ARTIFACT_DIR/evaos-desktop-bridge-macos-arm64.tar.gz" + PAYLOAD_DIR="${RUNNER_TEMP}/evaos-desktop-bridge-payload" + MANIFEST_PATH="$PAYLOAD_DIR/payload-manifest.json" + if [ ! -f "$ARCHIVE_PATH" ] || [ -L "$ARCHIVE_PATH" ]; then + echo "::error::Desktop Bridge artifact must contain the fixed payload tar archive." + exit 1 + fi + rm -rf "$PAYLOAD_DIR" + mkdir -p "$PAYLOAD_DIR" + python3 - "$ARCHIVE_PATH" "$PAYLOAD_DIR" <<'PY' + import pathlib + import sys + import tarfile + + archive_path = pathlib.Path(sys.argv[1]) + payload_dir = pathlib.Path(sys.argv[2]) + max_payload_bytes = 1024 * 1024 * 1024 + max_file_bytes = 512 * 1024 * 1024 + max_members = 100_000 + max_path_bytes = 1024 + seen = set() + total_bytes = 0 + with tarfile.open(archive_path, mode="r:gz") as archive: + members = [] + for member in archive: + if len(members) >= max_members: + raise SystemExit("desktop_bridge_archive_exceeds_member_limit") + members.append(member) + for member in members: + relative = pathlib.PurePosixPath(member.name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit("unsafe_path_in_desktop_bridge_archive") + if any(ord(char) < 32 or ord(char) == 127 for char in member.name): + raise SystemExit("control_character_in_desktop_bridge_archive_path") + if len(member.name.encode("utf-8")) > max_path_bytes: + raise SystemExit("desktop_bridge_archive_path_exceeds_limit") + normalized = relative.as_posix().removeprefix("./") + if not normalized or normalized in seen: + raise SystemExit("duplicate_or_empty_desktop_bridge_archive_path") + seen.add(normalized) + if not (member.isfile() or member.isdir()): + raise SystemExit("link_or_special_file_in_desktop_bridge_archive") + if member.isfile(): + if member.size > max_file_bytes: + raise SystemExit("desktop_bridge_archive_file_exceeds_size_limit") + total_bytes += member.size + if total_bytes > max_payload_bytes: + raise SystemExit("desktop_bridge_archive_exceeds_size_limit") + archive.extractall(payload_dir, members=members, filter="data") + PY if [ ! -f "$MANIFEST_PATH" ] || [ -L "$MANIFEST_PATH" ]; then - echo "::error::Desktop Bridge artifact must place one regular payload-manifest.json at its extraction root." + echo "::error::Desktop Bridge archive must place one regular payload-manifest.json at its extraction root." exit 1 fi if find "$PAYLOAD_DIR" -type l -print -quit | grep -q .; then @@ -1013,6 +1061,7 @@ jobs: startsWith(matrix.platform, 'macos') shell: bash run: | + set -euo pipefail shopt -s nullglob zips=( out/evaOS\ Workbench-*-mac-*.zip @@ -1024,6 +1073,18 @@ jobs: fi printf 'Found macOS ZIP artifact(s):\n' printf ' %s\n' "${zips[@]}" + for zip_path in "${zips[@]}"; do + extract_dir="$(mktemp -d "${RUNNER_TEMP}/evaos-updater-zip.XXXXXX")" + ditto -x -k "$zip_path" "$extract_dir" + apps=("$extract_dir"/*.app) + if [ "${#apps[@]}" -ne 1 ]; then + echo "::error::$zip_path must contain exactly one top-level signed Workbench app." + rm -rf "$extract_dir" + exit 1 + fi + codesign --verify --deep --strict --verbose=2 "${apps[0]}" + rm -rf "$extract_dir" + done - name: Write macOS arm64 updater metadata if: >- diff --git a/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md b/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md index 7ce0773d38..dd39c56245 100644 --- a/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md +++ b/docs/superpowers/specs/2026-07-12-workbench-fresh-mac-zero-touch-design.md @@ -1,6 +1,6 @@ # Workbench Fresh-Mac Zero-Touch Design -Status: proposed for written-spec review +Status: approved for implementation by the product owner on 2026-07-12 Date: 2026-07-12 @@ -94,16 +94,16 @@ The main process owns probing and classification. The renderer receives typed, r Workbench presents the first unmet prerequisite with one primary action: -| State | Customer action | Workbench behavior | -| --- | --- | --- | -| Bridge missing/incompatible | Reinstall/Update Workbench | Open the approved updater or public artifact; never request Python | -| Network client missing | Install secure network | Open the approved macOS installation surface and recheck after installation | -| Network client stopped | Open secure network | Open/activate the client and recheck | -| Unenrolled | Connect this Mac | Request short-lived broker-owned enrollment material only after the click; keep it in the main process and redact all output | -| Wrong control plane | Reconnect correctly | Fail closed, revoke unusable pending material, and run the approved re-enrollment flow | -| ACL blocked/offline | Retry or contact support | Report the exact layer without displaying addresses or ACL contents | -| CUA absent, Peekaboo ready | Continue | Mark CUA optional and automatically select bundled Peekaboo | -| No action engine | Repair Workbench | Fail before pairing/control, with a precise packaged-resource error | +| State | Customer action | Workbench behavior | +| --------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Bridge missing/incompatible | Reinstall/Update Workbench | Open the approved updater or public artifact; never request Python | +| Network client missing | Install secure network | Open the approved macOS installation surface and recheck after installation | +| Network client stopped | Open secure network | Open/activate the client and recheck | +| Unenrolled | Connect this Mac | Request short-lived broker-owned enrollment material only after the click; keep it in the main process and redact all output | +| Wrong control plane | Reconnect correctly | Fail closed, revoke unusable pending material, and run the approved re-enrollment flow | +| ACL blocked/offline | Retry or contact support | Report the exact layer without displaying addresses or ACL contents | +| CUA absent, Peekaboo ready | Continue | Mark CUA optional and automatically select bundled Peekaboo | +| No action engine | Repair Workbench | Fail before pairing/control, with a precise packaged-resource error | The first release does not silently install system extensions or enroll a Mac without a user click. Any privilege or macOS VPN approval remains an unavoidable signed OS prompt for the correct identity. diff --git a/scripts/afterPack.js b/scripts/afterPack.js index e827eae216..846df6d302 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -214,6 +214,24 @@ function requirePinnedBridgePayloadIdentity(resourcesDir, manifest, env = proces if (actualPayload.sha256 !== payloadSha256 || actualPayload.fileCount !== manifest.payload.fileCount) { throw new Error('Release macOS bridge resource does not match its pinned immutable payload identity.'); } + let producerManifest; + try { + producerManifest = JSON.parse(fs.readFileSync(producerManifestPath, 'utf8')); + } catch { + throw new Error('Release macOS bridge resource producer manifest is unreadable.'); + } + if ( + producerManifest?.schema_version !== 1 || + producerManifest?.source?.repository !== 'electricsheephq/evaos-desktop-bridge' || + producerManifest?.source?.commit !== manifest.sourceCommit || + producerManifest?.target?.platform !== manifest.payload.target.platform || + producerManifest?.target?.architecture !== manifest.payload.target.architecture || + producerManifest?.payload?.algorithm !== manifest.payload.algorithm || + producerManifest?.payload?.sha256 !== manifest.payload.sha256 || + producerManifest?.payload?.file_count !== manifest.payload.fileCount + ) { + throw new Error('Release macOS bridge producer and resource manifests do not describe the same payload.'); + } } function requireManagedNodeRuntime(resourcesDir, runtimeKey, electronPlatformName, missing) { diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 98cb142300..69154789b2 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -1541,12 +1541,17 @@ function inspectMacosZipBridgePayload(zipPath) { ' if part.endswith(".app") and parts[index + 1:index + 4] == ["Contents", "Resources", "Bridge"]:', ' return "/".join(parts[index + 4:])', ' return None', - 'result = {"hasBridgeExecutable": False, "bridgeExecutableMachO": False, "hasBridgeManifest": False, "hasProducerManifest": False, "hasPrivateRuntime": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestV2Valid": False, "producerManifestValid": False, "producerManifestDigestValid": False, "payloadMetadataValid": False, "payloadFileCountValid": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', + 'result = {"hasBridgeExecutable": False, "bridgeExecutableMachO": False, "hasBridgeManifest": False, "hasProducerManifest": False, "hasPrivateRuntime": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestV2Valid": False, "producerManifestValid": False, "producerManifestDigestValid": False, "producerManifestCrossLinkValid": False, "producerLicenseCoverageValid": False, "bridgeEntryNamesUnique": True, "payloadMetadataValid": False, "payloadFileCountValid": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False}', 'entries = {}', + 'bridge_entries = {}', 'payload_file_count = 0', 'with zipfile.ZipFile(path) as archive:', ' for name in archive.namelist():', ' suffix = bridge_entry_suffix(name)', + ' if suffix is not None and not name.endswith("/"):', + ' if suffix in bridge_entries:', + ' result["bridgeEntryNamesUnique"] = False', + ' bridge_entries[suffix] = name', ' if suffix is not None and suffix not in {"manifest.json", "payload-manifest.json"} and not name.endswith("/"):', ' payload_file_count += 1', ' if suffix is not None and suffix.startswith("_internal/") and not name.endswith("/"):', @@ -1582,6 +1587,42 @@ function inspectMacosZipBridgePayload(zipPath) { ' result["manifestV2Valid"] = manifest.get("schema") == "evaos-desktop-bridge-resource/v2" and manifest.get("producerManifest") == "payload-manifest.json" and re.fullmatch(r"[0-9a-f]{40}", str(manifest.get("sourceCommit", ""))) is not None', ' result["producerManifestValid"] = producer_manifest.get("schema_version") == 1 and producer_manifest.get("target") == {"platform": "macos", "architecture": "arm64"} if isinstance(producer_manifest, dict) else False', ' result["producerManifestDigestValid"] = bool(producer_manifest_bytes) and hashlib.sha256(producer_manifest_bytes).hexdigest() == manifest.get("producerManifestSha256")', + ' producer_source = producer_manifest.get("source", {}) if isinstance(producer_manifest, dict) else {}', + ' producer_payload = producer_manifest.get("payload", {}) if isinstance(producer_manifest, dict) else {}', + ' result["producerManifestCrossLinkValid"] = producer_source.get("repository") == "electricsheephq/evaos-desktop-bridge" and producer_source.get("commit") == manifest.get("sourceCommit") and producer_manifest.get("target") == payload.get("target") and producer_payload.get("algorithm") == payload.get("algorithm") and producer_payload.get("sha256") == payload.get("sha256") and producer_payload.get("file_count") == payload.get("fileCount")', + ' producer_toolchain = producer_manifest.get("toolchain", {}) if isinstance(producer_manifest, dict) else {}', + ' producer_files = producer_manifest.get("files", {}) if isinstance(producer_manifest, dict) else {}', + ' dependencies = producer_toolchain.get("dependencies", []) if isinstance(producer_toolchain, dict) else []', + ' license_components = producer_toolchain.get("license_components", []) if isinstance(producer_toolchain, dict) else []', + ' license_files = producer_files.get("licenses", []) if isinstance(producer_files, dict) else []', + ' def normalized_distribution_name(value):', + ' return re.sub(r"[-_.]+", "-", str(value).strip().lower())', + ' try:', + ' dependency_names = [normalized_distribution_name(item["name"]) for item in dependencies]', + ' component_names = [item["component"] for item in license_components]', + ' file_components = [item["component"] for item in license_files]', + ' component_license_paths = [str(item["path"]) for item in license_components]', + ' file_license_paths = [str(item["path"]) for item in license_files]', + ' coverage_valid = bool(dependency_names) and len(dependency_names) == len(set(dependency_names)) and len(component_names) == len(set(component_names)) and len(file_components) == len(set(file_components)) and set(component_names) == set(file_components) and len(component_license_paths) == len(set(component_license_paths)) and len(file_license_paths) == len(set(file_license_paths))', + ' files_by_component = {item["component"]: item for item in license_files}', + ' covered_dependencies = set()', + ' for component in license_components:', + ' license_file = files_by_component.get(component["component"], {})', + ' mapped = component.get("dependency_names")', + ' license_path = component.get("path")', + ' path_parts = pathlib.PurePosixPath(str(license_path)).parts', + ' coverage_valid = coverage_valid and isinstance(mapped, list) and len(mapped) == len(set(mapped)) and all(name == normalized_distribution_name(name) for name in mapped) and str(license_path).startswith("licenses/") and pathlib.PurePosixPath(str(license_path)).as_posix() == str(license_path) and ".." not in path_parts and component.get("version") == license_file.get("version") and component.get("license") == license_file.get("license") and license_path == license_file.get("path")', + ' for dependency_name in mapped if isinstance(mapped, list) else []:', + ' coverage_valid = coverage_valid and dependency_name in dependency_names and dependency_name not in covered_dependencies', + ' covered_dependencies.add(dependency_name)', + ' license_entry = bridge_entries.get(str(license_path))', + ' coverage_valid = coverage_valid and license_entry is not None and re.fullmatch(r"[0-9a-f]{64}", str(license_file.get("sha256", ""))) is not None', + ' if license_entry is not None:', + ' coverage_valid = coverage_valid and hashlib.sha256(archive.read(license_entry)).hexdigest() == license_file.get("sha256")', + ' required_components = {"CPython", "PyInstaller", "PyObjC", "evaos-desktop-bridge", "Peekaboo"}', + ' result["producerLicenseCoverageValid"] = coverage_valid and required_components.issubset(set(component_names)) and covered_dependencies == set(dependency_names)', + ' except (KeyError, TypeError, ValueError):', + ' result["producerLicenseCoverageValid"] = False', ' result["payloadMetadataValid"] = payload.get("algorithm") == "sha256-tree-v1" and re.fullmatch(r"[0-9a-f]{64}", str(payload_sha256)) is not None and payload.get("target") == {"platform": "macos", "architecture": "arm64"} and isinstance(payload.get("fileCount"), int)', ' result["payloadFileCountValid"] = payload.get("fileCount") == payload_file_count', ' result["manifestSourceDigestValid"] = peekaboo.get("version") == expected_version and peekaboo.get("sourceSha256") == expected_source_sha256', @@ -1638,6 +1679,8 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { assertZipBridgeProbe(probe, 'hasProducerManifest', zipName, 'producer manifest'); assertZipBridgeProbe(probe, 'producerManifestValid', zipName, 'producer manifest identity'); assertZipBridgeProbe(probe, 'producerManifestDigestValid', zipName, 'producer manifest digest'); + assertZipBridgeProbe(probe, 'producerManifestCrossLinkValid', zipName, 'producer manifest cross-link'); + assertZipBridgeProbe(probe, 'bridgeEntryNamesUnique', zipName, 'unique Bridge entry names'); assertZipBridgeProbe(probe, 'payloadMetadataValid', zipName, 'payload metadata'); assertZipBridgeProbe(probe, 'payloadFileCountValid', zipName, 'payload file count'); assertZipBridgeProbe(probe, 'hasPrivateRuntime', zipName, 'private runtime'); @@ -1646,6 +1689,7 @@ function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { assertZipBridgeProbe(probe, 'hasConnectorHelper', zipName, 'connector helper'); assertZipBridgeProbe(probe, 'connectorHelperMachO', zipName, 'connector helper Mach-O shape'); assertZipBridgeProbe(probe, 'hasPeekabooLicense', zipName, 'Peekaboo license'); + assertZipBridgeProbe(probe, 'producerLicenseCoverageValid', zipName, 'producer license coverage'); assertZipBridgeProbe(probe, 'manifestPlaceholderFalse', zipName, 'non-placeholder manifest'); assertZipBridgeProbe(probe, 'manifestSourceDigestValid', zipName, 'Peekaboo source digest'); assertZipBridgeProbe(probe, 'manifestLicenseMetadataValid', zipName, 'Peekaboo license metadata'); diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index 4ac7b3ad04..6473c4a702 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -476,6 +476,22 @@ function isExactVersionIdentity(value) { ); } +function normalizedDistributionName(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[-_.]+/g, '-'); +} + +function isCanonicalLicensePath(value) { + const licensePath = String(value || ''); + return ( + licensePath.startsWith('licenses/') && + !path.posix.isAbsolute(licensePath) && + path.posix.normalize(licensePath) === licensePath + ); +} + function resolvePayloadFile(payloadDir, relativePath, description) { const normalized = payloadRelativePath(String(relativePath || '').trim()); if (!normalized || path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) { @@ -568,11 +584,16 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected ) { throw new Error('Desktop Bridge payload source identity is incomplete or mutable.'); } + const pythonToolchain = manifest.toolchain?.python; if ( - !isExactVersionIdentity(manifest.toolchain?.python) || + pythonToolchain?.implementation !== 'CPython' || + !isExactVersionIdentity(pythonToolchain?.version) || !String(manifest.toolchain?.freezer?.name || '').trim() || !isExactVersionIdentity(manifest.toolchain?.freezer?.version) ) { + if (pythonToolchain?.implementation !== 'CPython' || !isExactVersionIdentity(pythonToolchain?.version)) { + throw new Error('Desktop Bridge payload CPython toolchain identity is incomplete or invalid.'); + } throw new Error('Desktop Bridge payload toolchain identity is incomplete.'); } const dependencies = manifest.toolchain?.dependencies; @@ -588,6 +609,10 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected ) { throw new Error('Desktop Bridge payload toolchain dependency lock is incomplete or mutable.'); } + const dependencyNames = dependencies.map((dependency) => normalizedDistributionName(dependency.name)); + if (new Set(dependencyNames).size !== dependencyNames.length) { + throw new Error('Desktop Bridge payload toolchain dependency lock contains duplicate names.'); + } const expectedSha256 = requireSha256(expectedPayloadSha256, 'out-of-band payload'); const recordedSha256 = requireSha256(manifest.payload?.sha256, 'manifest payload'); @@ -634,7 +659,7 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected !isExactVersionIdentity(license?.version) || !String(license?.license || '').trim() || !String(license?.name || '').trim() || - !String(license?.path || '').startsWith('licenses/') + !isCanonicalLicensePath(license?.path) ) { throw new Error(`Desktop Bridge payload license ${index + 1} has invalid identity.`); } @@ -644,6 +669,44 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected const licenseComponents = Array.isArray(manifest.toolchain?.license_components) ? manifest.toolchain.license_components : []; + const licenseFilePaths = manifest.files.licenses.map((entry) => String(entry?.path || '')); + const componentLicensePaths = licenseComponents.map((entry) => String(entry?.path || '')); + if ( + new Set(licenseFilePaths).size !== licenseFilePaths.length || + new Set(componentLicensePaths).size !== componentLicensePaths.length + ) { + throw new Error('Desktop Bridge payload license inventory contains duplicate license paths.'); + } + const licensedDependencyNames = new Set(); + for (const componentIdentity of licenseComponents) { + const licenseFile = manifest.files.licenses.find((entry) => entry?.component === componentIdentity?.component); + const mappedDependencies = componentIdentity?.dependency_names; + if ( + !licenseFile || + !Array.isArray(mappedDependencies) || + mappedDependencies.some( + (dependencyName) => + !String(dependencyName || '').trim() || dependencyName !== normalizedDistributionName(dependencyName) + ) || + new Set(mappedDependencies).size !== mappedDependencies.length || + !isExactVersionIdentity(componentIdentity.version) || + !String(componentIdentity.license || '').trim() || + !isCanonicalLicensePath(componentIdentity.path) || + componentIdentity.version !== licenseFile.version || + componentIdentity.license !== licenseFile.license || + componentIdentity.path !== licenseFile.path + ) { + throw new Error( + `Desktop Bridge payload license identity is incomplete for ${String(componentIdentity?.component || 'unknown component')}.` + ); + } + for (const dependencyName of mappedDependencies) { + if (!dependencyNames.includes(dependencyName) || licensedDependencyNames.has(dependencyName)) { + throw new Error(`Desktop Bridge payload dependency license mapping is invalid for ${dependencyName}.`); + } + licensedDependencyNames.add(dependencyName); + } + } for (const component of requiredLicenseComponents) { const componentIdentity = licenseComponents.find((entry) => entry?.component === component); const licenseFile = manifest.files.licenses.find((entry) => entry?.component === component); @@ -652,7 +715,7 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected !licenseFile || !isExactVersionIdentity(componentIdentity.version) || !String(componentIdentity.license || '').trim() || - !String(componentIdentity.path || '').startsWith('licenses/') || + !isCanonicalLicensePath(componentIdentity.path) || componentIdentity.version !== licenseFile.version || componentIdentity.license !== licenseFile.license || componentIdentity.path !== licenseFile.path @@ -660,6 +723,11 @@ function validatePinnedBridgePayload(payloadDir, expectedPayloadSha256, expected throw new Error(`Desktop Bridge payload license coverage is incomplete for ${component}.`); } } + for (const dependencyName of dependencyNames) { + if (!licensedDependencyNames.has(dependencyName)) { + throw new Error(`Desktop Bridge payload license coverage is incomplete for dependency ${dependencyName}.`); + } + } const machOFiles = listPayloadFiles(payloadDir) .filter(({ absolutePath }) => isMachOFile(absolutePath)) .map(({ absolutePath, relativePath }) => { diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index f4dcef7d69..cc72423fe0 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -60,6 +60,7 @@ function writeBridgeFixture( pinnedPayload?: boolean; payloadSha256?: string; producerManifestSha256?: string; + producerSourceCommit?: string; } = {} ): { manifestSha256?: string; payloadSha256?: string } { const bridgeDir = join(resourcesDir, 'Bridge'); @@ -85,9 +86,18 @@ function writeBridgeFixture( } } if (options.pinnedPayload) { - const producerManifest = '{"schema_version":1}\n'; - writeFileSync(join(bridgeDir, 'payload-manifest.json'), producerManifest); const payload = bridgeResource.computePayloadTreeDigest(bridgeDir); + const sourceCommit = '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b'; + const producerManifest = `${JSON.stringify({ + schema_version: 1, + target: { platform: 'macos', architecture: 'arm64' }, + source: { + repository: 'electricsheephq/evaos-desktop-bridge', + commit: options.producerSourceCommit || sourceCommit, + }, + payload: { algorithm: payload.algorithm, sha256: payload.sha256, file_count: payload.fileCount }, + })}\n`; + writeFileSync(join(bridgeDir, 'payload-manifest.json'), producerManifest); const manifestSha256 = options.producerManifestSha256 || createHash('sha256').update(producerManifest).digest('hex'); const payloadSha256 = options.payloadSha256 || payload.sha256; @@ -98,7 +108,7 @@ function writeBridgeFixture( placeholder: false, producerManifest: 'payload-manifest.json', producerManifestSha256: manifestSha256, - sourceCommit: '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b', + sourceCommit, payload: { algorithm: payload.algorithm, sha256: payloadSha256, @@ -269,6 +279,21 @@ describe('afterPack bundled resource verification', () => { ); }); + it('rejects a release payload whose producer and resource identities do not cross-link', () => { + const resourcesDir = makeTempResources(); + const identity = writeBridgeFixture(resourcesDir, { + helper: true, + nativeHelpers: true, + nativeRoot: true, + pinnedPayload: true, + producerSourceCommit: '0'.repeat(40), + }); + + expect(() => + afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', strictBridgeEnv(identity), 'arm64') + ).toThrow(/producer and resource manifests do not describe the same payload/); + }); + it('rejects a release payload that differs from the configured out-of-band pin', () => { const resourcesDir = makeTempResources(); const identity = writeBridgeFixture(resourcesDir, { diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index c502cf1f0b..250010a5bd 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -132,6 +132,8 @@ function writeMacosBridgeZip( scriptBridge?: boolean; sourceSha256?: string; manifestLicenseSha256?: string; + producerSourceCommit?: string; + tamperTransitiveLicense?: boolean; } = {} ) { const script = [ @@ -153,10 +155,21 @@ function writeMacosBridgeZip( 'license_bytes = b"MIT License\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\n"', 'license_sha256 = manifest_license_sha256 or hashlib.sha256(license_bytes).hexdigest()', 'peekaboo = {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}', - 'producer_manifest = {"schema_version": 1, "target": {"platform": "macos", "architecture": "arm64"}, "source": {"repository": "electricsheephq/evaos-desktop-bridge", "commit": "60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b", "version": "0.7.0"}}', + 'producer_source_commit = sys.argv[10]', + 'tamper_transitive_license = sys.argv[11] == "1"', + 'license_specs = [', + ' {"component": "CPython", "version": "3.12.13", "license": "Python-2.0", "path": "licenses/CPython-LICENSE.txt", "dependency_names": []},', + ' {"component": "PyInstaller", "version": "6.21.0", "license": "GPL-2.0-or-later WITH Bootloader-exception", "path": "licenses/PyInstaller-COPYING.txt", "dependency_names": []},', + ' {"component": "PyObjC", "version": "12.2.1", "license": "MIT", "path": "licenses/PyObjC-LICENSE.txt", "dependency_names": ["pyobjc-core"]},', + ' {"component": "evaos-desktop-bridge", "version": "0.7.0", "license": "MIT", "path": "licenses/evaos-desktop-bridge-MIT.txt", "dependency_names": []},', + ' {"component": "Peekaboo", "version": "3.8.0", "license": "MIT", "path": "licenses/Peekaboo-LICENSE.txt", "dependency_names": []},', + ']', + 'license_files = [{**item, "name": item["component"] + " license", "sha256": hashlib.sha256(license_bytes).hexdigest()} for item in license_specs]', + 'for item in license_files: item.pop("dependency_names")', + 'payload_file_count = 9 - int(omit_peekaboo) - int(omit_license) - int(omit_internal_runtime)', + 'producer_manifest = {"schema_version": 1, "target": {"platform": "macos", "architecture": "arm64"}, "source": {"repository": "electricsheephq/evaos-desktop-bridge", "commit": producer_source_commit, "version": "0.7.0"}, "toolchain": {"dependencies": [{"name": "pyobjc-core", "version": "12.2.1", "sha256": "a" * 64}], "license_components": license_specs}, "files": {"licenses": license_files}, "payload": {"algorithm": "sha256-tree-v1", "sha256": "1" * 64, "file_count": payload_file_count}}', 'producer_manifest_bytes = (json.dumps(producer_manifest, sort_keys=True) + "\\n").encode()', 'producer_manifest_sha256 = hashlib.sha256(producer_manifest_bytes).hexdigest()', - 'payload_file_count = 5 - int(omit_peekaboo) - int(omit_license) - int(omit_internal_runtime)', 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": peekaboo}} if legacy_manifest else {"schema": "evaos-desktop-bridge-resource/v2", "placeholder": False, "producerManifest": "payload-manifest.json", "producerManifestSha256": producer_manifest_sha256, "sourceCommit": "60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b", "payload": {"algorithm": "sha256-tree-v1", "sha256": "1" * 64, "fileCount": payload_file_count, "target": {"platform": "macos", "architecture": "arm64"}}, "bundledTools": {"peekaboo": peekaboo}}', 'with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:', ' bridge_bytes = b"#!/usr/bin/env bash\\n" if script_bridge else bytes.fromhex("cffaedfe0c00000100000000")', @@ -166,6 +179,11 @@ function writeMacosBridgeZip( ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"))', ' if not omit_license:', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/licenses/Peekaboo-LICENSE.txt", license_bytes)', + ' for item in license_specs:', + ' if item["component"] != "Peekaboo":', + ' contents = b"tampered transitive license" if tamper_transitive_license and item["component"] == "PyObjC" else license_bytes', + ' license_path = item["path"]', + ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/{license_path}", contents)', ' if not omit_internal_runtime:', ' archive.writestr(f"{app_root}/Contents/Resources/Bridge/_internal/runtime.dat", b"private runtime")', ' if not legacy_manifest:', @@ -186,6 +204,8 @@ function writeMacosBridgeZip( options.scriptBridge ? '1' : '0', options.omitInternalRuntime ? '1' : '0', options.legacyManifest ? '1' : '0', + options.producerSourceCommit || '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b', + options.tamperTransitiveLicense ? '1' : '0', ]); } @@ -463,8 +483,15 @@ describe('evaOS beta release gate', () => { expect(workflow).toContain('repository: ${{ inputs.desktop_bridge_payload_repository }}'); expect(workflow).toContain('run-id: ${{ inputs.desktop_bridge_payload_run_id }}'); expect(workflow).toContain('github-token: ${{ secrets.EVAOS_DESKTOP_BRIDGE_ACTIONS_TOKEN'); - expect(workflow).toContain('MANIFEST_PATH="$DOWNLOAD_DIR/payload-manifest.json"'); - expect(workflow).toContain('PAYLOAD_DIR="$DOWNLOAD_DIR"'); + expect(workflow).toContain('ARCHIVE_PATH="$ARTIFACT_DIR/evaos-desktop-bridge-macos-arm64.tar.gz"'); + expect(workflow).toContain('MANIFEST_PATH="$PAYLOAD_DIR/payload-manifest.json"'); + expect(workflow).toContain('archive.extractall(payload_dir, members=members, filter="data")'); + expect(workflow).toContain('link_or_special_file_in_desktop_bridge_archive'); + expect(workflow).toContain('desktop_bridge_archive_exceeds_member_limit'); + expect(workflow).toContain('desktop_bridge_archive_path_exceeds_limit'); + expect(workflow).toContain('desktop_bridge_archive_file_exceeds_size_limit'); + expect(workflow).toContain('ditto -x -k "$zip_path" "$extract_dir"'); + expect(workflow).toContain('codesign --verify --deep --strict --verbose=2 "${apps[0]}"'); expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_PAYLOAD_DIR='); expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_PAYLOAD_SHA256='); expect(workflow).toContain('EVAOS_DESKTOP_BRIDGE_MANIFEST_SHA256='); @@ -1480,6 +1507,11 @@ describe('evaOS beta release gate', () => { options: { legacyManifest: true }, expected: /v2 payload manifest/, }, + { + name: 'producer and resource manifest source mismatch', + options: { producerSourceCommit: '0'.repeat(40) }, + expected: /producer manifest cross-link/, + }, { name: 'missing private runtime', options: { omitInternalRuntime: true }, @@ -1500,6 +1532,11 @@ describe('evaOS beta release gate', () => { options: { omitLicense: true }, expected: /Peekaboo license/, }, + { + name: 'tampered transitive license', + options: { tamperTransitiveLicense: true }, + expected: /producer license coverage/, + }, { name: 'mismatched license digest', options: { manifestLicenseSha256: '0'.repeat(64) }, diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index 17f1380bd7..58d8661e85 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -237,6 +237,76 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('requires the exact CPython implementation and version shape', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-python-identity-')); + try { + const payloadDir = join(dir, 'payload'); + const { payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.toolchain.python.implementation = 'unexpected-runtime'; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const manifestSha256 = createHash('sha256').update(readFileSync(manifestPath)).digest('hex'); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/CPython toolchain identity/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('requires every locked dependency to map to one authenticated license file', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-dependency-license-')); + try { + const payloadDir = join(dir, 'payload'); + const { payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.toolchain.license_components.find( + (entry: { component: string }) => entry.component === 'PyObjC' + ).dependency_names = []; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const manifestSha256 = createHash('sha256').update(readFileSync(manifestPath)).digest('hex'); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/license coverage.*pyobjc-core/i); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('rejects license components that alias one physical license path', () => { + const dir = mkdtempSync(join(tmpdir(), 'evaos-bridge-license-alias-')); + try { + const payloadDir = join(dir, 'payload'); + const { payloadSha256 } = writePinnedPayloadFixture(payloadDir); + const manifestPath = join(payloadDir, 'payload-manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const cpythonLicense = manifest.files.licenses.find( + (entry: { component: string }) => entry.component === 'CPython' + ); + const pyobjcLicense = manifest.files.licenses.find( + (entry: { component: string }) => entry.component === 'PyObjC' + ); + const pyobjcComponent = manifest.toolchain.license_components.find( + (entry: { component: string }) => entry.component === 'PyObjC' + ); + pyobjcLicense.path = cpythonLicense.path; + pyobjcLicense.sha256 = cpythonLicense.sha256; + pyobjcComponent.path = cpythonLicense.path; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const manifestSha256 = createHash('sha256').update(readFileSync(manifestPath)).digest('hex'); + + expect(() => + bridgeResource.preparePinnedBridgePayload(payloadDir, join(dir, 'Bridge'), payloadSha256, manifestSha256) + ).toThrow(/duplicate license paths/i); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + it('refuses the legacy host-Python wrapper path for strict release preparation', () => { expect(() => bridgeResource.resolvePinnedBridgeConfiguration( @@ -542,25 +612,45 @@ function writePinnedPayloadFixture( version: '0.7.0', }, toolchain: { - python: '3.12.12', + python: { implementation: 'CPython', version: '3.12.13' }, freezer: { name: 'pyinstaller', version: '6.16.0' }, dependencies: [{ name: 'pyobjc-core', version: '11.1', sha256: 'a'.repeat(64) }], license_components: [ - { component: 'CPython', version: '3.12.12', license: 'PSF-2.0', path: licenseFiles.CPython }, + { + component: 'CPython', + version: '3.12.13', + license: 'PSF-2.0', + path: licenseFiles.CPython, + dependency_names: [], + }, { component: 'PyInstaller', version: '6.16.0', license: 'GPL-2.0-or-later-with-bootloader-exception', path: licenseFiles.PyInstaller, + dependency_names: [], + }, + { + component: 'PyObjC', + version: '11.1', + license: 'MIT', + path: licenseFiles.PyObjC, + dependency_names: ['pyobjc-core'], }, - { component: 'PyObjC', version: '11.1', license: 'MIT', path: licenseFiles.PyObjC }, { component: 'evaos-desktop-bridge', version: '0.7.0', license: 'Proprietary', path: licenseFiles['evaos-desktop-bridge'], + dependency_names: [], + }, + { + component: 'Peekaboo', + version: '3.8.0', + license: 'MIT', + path: licenseFiles.Peekaboo, + dependency_names: [], }, - { component: 'Peekaboo', version: '3.8.0', license: 'MIT', path: licenseFiles.Peekaboo }, ], }, files: { @@ -581,7 +671,7 @@ function writePinnedPayloadFixture( component, version: component === 'CPython' - ? '3.12.12' + ? '3.12.13' : component === 'PyInstaller' ? '6.16.0' : component === 'PyObjC'