An agent working in an area gets root + that area's AGENTS.md as one project-context block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k): update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway (20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md. apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal -> BUILDING.md.
20 KiB
Building the Desktop Installers
Use the complete bundle builder for release artifacts. Ordinary dist:*
commands package the current desktop build; they do not stage a fresh runtime.
Artifact and update ownership
| Target | Artifact | Update owner |
|---|---|---|
| Windows x64 / ARM64, sideload | Signed per-architecture MSIX packages, combined into a universal .msixbundle; .appinstaller descriptor |
Windows App Installer |
| Windows x64 / ARM64, Store | Store-identity MSIX packages and a separate Store bundle | Microsoft Store |
| macOS ARM64 / x64 | Signed, notarized Hermes.app in DMG and ZIP artifacts |
electron-updater with Squirrel.Mac |
| Linux x64 / ARM64 | Local builder can produce AppImage | External replacement; Linux desktop release legs are disabled |
The current MSIX manifest requires Windows 11 22H2 (10.0.22621.0).
The source-script Windows support range is separate from this package floor.
Windows desktop packaging uses MSIX, not NSIS or MSI.
bundled carries the local runtime. store carries the same runtime under
Partner Center's package identity. light is a remote-only client without
Python or a local agent. Light has build/feed support but no release matrix leg.
Payload contents
The bundled payload contains:
- The release's source snapshot without
.git. - Pinned CPython and uv, plus a ready Python dependency tree.
- Node.js, npm, supported managed tools, and prebuilt TUI/dashboard assets.
- A manifest, tool facts, the installed feature list, and build provenance.
- Generated CLI launchers from the archived project's script declarations.
pm/lock.json owns managed-tool pins. pyproject.toml and uv.lock own Python
requirements. Native staging uses --all-extras, subject to platform markers,
minus the extras [tool.hermes] opt-in-extras names (installed only when the
user selects them). This is broader than the source installer's extra named all.
The backend runs from app resources. Launchers execute the store interpreter with the source and dependency paths; they do not boot through a relocated venv executable. Startup can create user-state records and CLI links. Provider calls, model downloads, and optional integration setup can still use the network.
Git is a platform exception: Windows stages Git for Windows with Bash. POSIX targets use system Git. A Mac without Command Line Tools can therefore need them for Git-dependent operations.
User data remains outside the package. Optional additions use writable PM storage and complete Python environment generations, not writes into the app. See Package management.
Python runtime contract
The PM bundle builder records interpreter and dependency paths in the payload
manifest. scripts/write-build-stamp.mjs copies that runtime contract into the
desktop stamp. The desktop build embeds the stamp in Electron.
electron/payload-backend.ts reads runtime.sitePackages from this stamp.
It does not select a Python version or search for dependency directories.
Staging does not prove native startup or package replacement. Those checks use the bundled-update acceptance suite. Acceptance requires a native run with the actual signed release artifacts.
Packaged tool facts describe the final bytes, not only the staged archives.
Windows refreshes the tool digests after sanitization and batch signing.
The macOS custom signer retains the builder's entitlements and file selection.
It refreshes digests after child signatures and before the outer app signature.
Unsigned macOS builds refresh them at the end of afterPack.
Do not refresh facts in afterSign: that changes resources covered by the signature.
Complete native build
From a clean checkout whose HEAD equals the release tag, run:
python scripts/bundles/desktop.py --tag=vX.Y.Z
Replace vX.Y.Z with an actual immutable tag. Stable tags must match the
version in pyproject.toml. Canary tags use the release script's tag grammar.
Start with host Python 3.11+ and Git. Use python3 if that is your host's command.
Preparation asks PM for the pinned Python, Node, npm and private installer;
preinstalling a separate Node/npm/uv toolchain is not required. Native dependency
builds still need the platform's compiler, SDK and libraries. Windows ARM64 uses
the shared Visual Studio/Clang/Rust/static OpenSSL preparation provider, which
can require Administrator permissions for missing system components. macOS
requires its native developer tools. This is not a hermetic host SDK.
The builder:
- Admits the clean source revision and prepares managed tools in isolated build state.
- Prepares the locked JS workspace union, icon environment, Electron-native bindings, packaging utilities, and the application/independent PM environments when selected.
- Compiles icons and the selected TUI, dashboard and desktop products.
- Assembles the PM payload, places JS assets, relocates links, and generates launchers.
- Consumes the prepared Electron archive and utilities to package the current OS.
Use --variant store with a stable tag on Windows, or --variant light for the remote client.
Arguments after -- go to the prepared wrapper, which rejects overrides of
the admitted target, tools, output and configuration. electron-builder.config.cjs
is the sole packaging configuration. The wrapper disables automatic publishing;
the release workflow owns uploads and channel promotion.
pm bundle --out DIR --ref REF stages the native runtime only.
scripts/bundles/stage.py also builds the TUI/dashboard unless both products
are supplied explicitly. Both generate launchers; neither creates an Electron installer.
The launcher stage checks the payload and records its relative launch paths.
The Electron build bakes these paths into its stamp. Desktop startup does not
inspect, create, or repair a PM payload. Non-bundled builds carry no placeholder payload.
See shared bundle builds for Termux reuse.
Prepare once, then build
CI and local builds share the same split interface:
python scripts/bundles/desktop.py --tag vX.Y.Z --variant bundled --prepare-only \
--work "$PWD/.build/desktop-job" --cache "$PWD/.cache/desktop-inputs"
python scripts/bundles/desktop.py --prepared "$PWD/.build/desktop-job/prepared.json"
The displayed paths are the defaults. Use separate build-owned work/cache roots;
do not pre-create the work directory. prepared.json is published after every
provider succeeds and contains absolute paths for this job, not a portable cache.
After relocation or a source/lock/tool change, prepare again. Consumption rejects
missing or changed dependencies without repairing or downloading them. Stable
Windows builds can consume one preparation for --variant bundled and then
--variant store; light and commit preparations cannot switch to Store.
Release jobs restore candidates, prepare, save reusable inputs, and only then build/sign. Job-local environments, credentials, products and signature results are not dependency snapshot inputs. The signature cache retains its own lifetime. Commit jobs request token-enforced read-only cache access; the conditional YAML mode still needs GitHub acceptance (see the cache-policy caveat). A separate key or skipped save alone would not protect release caches. Archival remains an independent prerequisite, and R2 upload credentials are not exposed to desktop preparation.
Strict consumption means no dependency acquisition, not offline signing. Timestamp services, Azure signing, Apple notarization and publication remain online operations. Validate unsigned packaging with dependency networking denied on each target, then verify signed installers and launchers on their native hosts.
macOS packaging retains the caller's login HOME for keychain import and signing.
An explicit keychain path does not make Security.framework work under a scratch
home. Dependency preparation and product compilation still use the isolated home.
Hermes state and explicit dependency-cache paths remain build-owned during packaging.
Commit-only builds
To preview a build for a pushed revision, run:
python scripts/release.py --build-commit REV --remote origin
The command fetches the remote and resolves REV to a full commit SHA.
It prints the dispatch command without changing local branches, tags, or releases.
Add --publish to dispatch that build. This flag does not publish a release
in commit-build mode.
Use repeatable --bundle-env NAME=VALUE options to bake non-secret runtime
defaults into the desktop app, for example:
python scripts/release.py --build-commit REV --remote fork --publish \
--bundle-env HERMES_GUEST_ONBOARDING=1 \
--bundle-env HERMES_DATA_DIR_SUFFIX=magic-test \
--bundle-unset HERMES_HOME
These defaults run before Electron initializes its paths and are inherited by
local backend processes. Explicit runtime environment values win, including
empty values. Do not pass secrets: the values are visible in the workflow inputs
and packaged JavaScript. This option affects desktop bundles, not Termux.
The suffix is appended literally; include a leading hyphen if desired.
--bundle-unset NAME explicitly clears an inherited value at app launch, even
if the caller supplied it. Internally it sets the value to an empty string, not
an absent key. Clearing HERMES_HOME also disables the Windows registry fallback:
older installers saved that variable permanently, which otherwise takes priority
over the test suffix. It does not edit the registry or the existing install.
For full data-path isolation, also clear HERMES_DESKTOP_USER_DATA_DIR if that
machine uses an explicit Electron directory. Ordinary defaults still preserve
runtime overrides; --bundle-env HERMES_HOME= is a default, not a forced clear.
For local commit builds, HERMES_BUNDLE_ENV_JSON accepts a JSON object whose
string values are defaults and whose null values are explicit clears. For example,
{"HERMES_HOME":null,"HERMES_DATA_DIR_SUFFIX":"magic-test"}. Only
HERMES_HOME, HERMES_DATA_DIR_SUFFIX, HERMES_DESKTOP_USER_DATA_DIR,
HERMES_SHARED_AUTH_DIR, HERMES_GUEST_ONBOARDING, and HERMES_SKIP_INTRO
are accepted. Process-control variables such as NODE_OPTIONS and PATH
are rejected. These settings are not applied to the build runner itself.
Commit archive keys still use the SHA, so use a fresh commit for different
defaults: an existing artifact is never overwritten with different bytes.
The workflow must exist on the repository's default branch. Admission requires
a default-branch workflow_dispatch and repository write, maintain, or admin
permission for both the original actor and the actor who reruns it.
It rejects mixed tag, release-phase, channel-publication, and upgrade inputs.
Builder jobs check out the admitted SHA. Their artifacts and completion receipts
go to releases/commit/FULL_SHA/, separate from tag archives and update channels.
The run summary lists Windows sideload packages and their universal bundle, macOS DMG/ZIP
files, and the Termux package. Linux release legs remain disabled and are listed
as not built. Only receipt-listed artifacts that exist in storage get download
links. Missing receipts show the failed or incomplete leg.
Each commit build also writes a downloads page to
releases/commit/<FULL_SHA>/index.html on the R2 public origin; a tag run
writes its channel page instead (releases/stable/index.html or
releases/canary/index.html). Pages list only objects the build actually
staged, and a re-run of an older tag never replaces a newer channel page.
release.py --build-commit prints the commit page URL before dispatching.
Commit builds require the signing credentials used by their release legs. They do not produce Store packages or submit to Partner Center. No GitHub release, updater feed, or APT channel is changed. An identical upload retry can succeed. Different bytes at an existing commit object key fail rather than replace that object.
For a local native build, check out the exact SHA and run:
python scripts/bundles/desktop.py --commit=FULL_SHA --variant=bundled
The builder uses that commit's project version. Sideload MSIX versions append
.0. The shared MSIX version helper can derive Store versions from commit timestamps,
but this desktop preparation interface only admits Store packaging for stable tags.
Commit-built stamps disable automatic release-channel updates. Local command and transport tests do not
replace signed-package installation and update acceptance on each native host.
Windows signing and App Installer
The signing jobs provide AZURE_SIGN_ENDPOINT, AZURE_SIGN_ACCOUNT,
AZURE_SIGN_PROFILE, AZURE_SIGN_PUBLISHER, and AZURE_CLIENT_ID, plus
Azure authentication. Do not pass publisher names through shell-split -c
arguments. Local unsigned builds are not release-acceptance artifacts.
The packaging hooks sanitize invalid PE certificate tables, then sign and
timestamp payload EXEs/DLLs. The product EXE receives its signature after
resource edits. scripts/sign-msix.mjs signs the sideload package envelope.
Store package envelopes remain unsigned for Partner Center to sign.
Unchanged payload files reuse verified signatures from
${ELECTRON_BUILDER_CACHE}-payload-signatures. The key binds input bytes,
Azure policy, signing tools, and timestamp policy. Filenames and release
versions do not determine identity. A hit must match executable content and
pass Authenticode publisher/timestamp checks. Invalid entries become misses.
The product EXE and package envelopes still receive fresh signatures.
The before-build.mjs hook generates MSIX extensions from payload launcher
names. One execution-alias extension carries the CLI aliases. The manifest
also registers the Copilot hardware-key provider. Its minimum Windows version
is part of the package contract.
The release job combines only matching sideload packages from both architectures.
Store packages never enter the sideload bundle. It publishes bundle bytes before
the channel descriptor at releases/win32/CHANNEL/CHANNEL.appinstaller.
Light uses releases/win32/light/CHANNEL/.
Windows records the descriptor source at installation. The app checks that
registered source through WinRT and distinguishes an unknown result from no
update. Apply downloads the descriptor before teardown, then opens the local
.appinstaller file. It does not require the disabled ms-appinstaller: protocol.
The app registers a detached relaunch waiter before handoff.
The build stamp declares updateMechanism: app-installer for sideload bundles,
microsoft-store for Store builds, electron-updater for macOS packages, and
self for source-built apps. Settings shows Microsoft Store for a Store build.
The runtime does not infer Store ownership from Electron flags or carry a
second Store boolean. Windows Light declares external because it has no
bundled Python checker. Its OS-registered App Installer source still owns
automatic updates.
Store builds use Windows.Services.Store.StoreContext to check, download,
and request installation inside Hermes. The native consent UI attaches to the
current desktop window. Download finishes before backend shutdown; the existing
relaunch waiter is registered before the install request. Unknown checks,
cancellation and request failures do not count as successful updates. Native
acceptance requires a Store-acquired package or flight, not a sideloaded MSIX.
On Windows bundles, PM copies the verified pinned base Python to its writable store for uv builds. The app and execution aliases retain their signed bundled launchers and bundled Python, which load the selected dependency generation. The new venv's generated console scripts must not replace those launchers: out-of-package Python cannot launch the packaged tools.
Sideload stable versions are X.Y.Z.0. Canary revisions derive from elapsed
minutes after the stable baseline. The release script rejects ambiguous or
overflowing cuts. Store versions use year.hour-of-year.second-of-hour.0 in UTC,
with the fourth component reserved for Microsoft. App semver and package
version are different facts. scripts/msix-shared.mjs owns these derivations.
macOS signing and updates
CSC_LINK and CSC_KEY_PASSWORD supply the Developer ID identity.
The notarization hook accepts APPLE_API_KEY, APPLE_API_KEY_ID, and
APPLE_API_ISSUER, or a keychain profile. CI materializes the API key from
APPLE_API_KEY_P8.
The app and nested Mach-O binaries, including Chromium, must be signed before notarization. The existing after-sign hook owns submission and stapling. Publication checks signatures, the stapled ticket, and Gatekeeper assessment. Unsigned local builds do not satisfy these gates.
Both DMG and ZIP artifacts are required by the release pipeline. The ZIP is the update artifact, not an optional duplicate of the download DMG. See macOS bundle updates for feed validation and native-event ordering.
DMG detach diagnostics
The builder wrapper reports [dmg-detach] snapshots when dmgbuild cannot detach
its staging image. It runs lsof before the supplier retries or performs forced
cleanup. The snapshot identifies the mounted filesystem, backing image and
devices, then lists process names, PIDs, parent PIDs, users, descriptors and paths.
The queries use noninteractive sudo when available. Permission failures and
query timeouts are reported explicitly. Empty output does not prove that the
image has no holder. The shim does not stop processes or change detach results,
retry settings, signing or notarization. Explicit CUSTOM_DMGBUILD_PATH
overrides outside the prepared path are not an escape hatch for strict builds.
Prepared packaging supplies the admitted dmgbuild path to the diagnostics shim.
Changes to its preparation provider need native DMG/detach and
signing/notarization acceptance; a successful download is not native execution proof.
Development, assets, and verification
Prepare and activate the PM developer environment
first. Use Bash source ./activate or PowerShell . .\activate.ps1, and keep a
separate development home. Activation supplies the toolchain, not node_modules.
From the repository root:
npm ci
npm run dev --workspace apps/desktop
For an ordinary package of that desktop build, use the workspace's
dist:win, dist:mac, dist:linux, or pack command. Those commands do not
replace the complete tagged build described above.
Icons are generated from assets/nous-girl-*.svg and assets/backgrounds/.
node scripts/generate-icons.mjs renders them with the Hermes runtime Python
(HERMES_PYTHON, else python on PATH); Pillow and resvg-py are core
dependencies. Generated PNG/ICO/ICNS files are not source assets.
Stable release admission requires the full pipeline, not just successful packaging. Native signed-package update tests live in the existing install/update family. A helper test or unpacked-app smoke is not proof of native install, update, or automatic relaunch. Historical receipts and current unresolved gates are separate in PM audit status.
Rehearsing the guided onboarding
From apps/desktop, use a fresh temporary directory for each rehearsal and run
env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME=<tmp>/.hermes HERMES_DESKTOP_USER_DATA_DIR=<tmp>/electron-user-data npm run dev
(replace <tmp> with that directory). To use the portal stand-in, add
HERMES_PORTAL_BASE_URL=http://127.0.0.1:8765 HERMES_ANON_API_SECRET=test-secret HERMES_SHARED_AUTH_DIR=<tmp>/.hermes/shared
before npm run dev. Stop Electron and its dev server after the run.