feat(desktop): bundle, publish, and update the desktop app as MSIX

Wire the desktop app onto the pm store for real distribution:
- MSIX bundle: electron-builder config, appx assets, manifest, copilot
  key + deep-link routing, App Installer + Windows Store variant
  (sign only the msix; inner binaries covered by the package block map)
- Rust CLI shim (apps/desktop/shim) — bundled builds run from the store
  python + shim, never the venv; payload symlinks relativized so the
  relocatable venv survives relocation
- Cloudflare R2 release pipeline: publish binaries + update feeds,
  nightly channels/tags, stamp-first version resolution
- Update system: gate, uninstall steward, boot bootstrap, release
  channels, update receipts
- install.ps1 reduced to a 361-line stage-protocol bootstrapper (heavy
  deps are pm's job); darwin updater + update-channel mirror ripped
- doctor: main's re-landed TCC anchor kept, termux branches removed

Rebuilt from ethie/pm onto the pm-store stack. 22 hot files hand-merged;
uv.lock + package-lock.json keep main's newer dep tree; test_engines
reads the pm/lock.json pin; lazy_deps.py deleted (all 222 importers
migrated to pm in the foundation commit).
This commit is contained in:
ethernet committed 2026-08-31 18:00:48 -04:00
1 parent f9656f111a
commit 47f4ab3a17
192 files changed
+17025 -10106

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
with:
node-version: 26
cache: npm
+6 -4
View File
@@ -94,6 +94,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
# Retry once on transient Docker Hub / buildkit pull failures
# (connection reset, auth token timeout, rate limiting). The action
# generates a unique builder name per invocation so the retry doesn't
@@ -119,8 +122,6 @@ jobs:
load: true
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
@@ -206,6 +207,9 @@ jobs:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
@@ -234,8 +238,6 @@ jobs:
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
with:
node-version: 26
cache: npm
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# ── Node ───────────────────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
with:
node-version: 26
cache: npm
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
with:
node-version: 26
cache: npm
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
with:
node-version: 26
cache: npm
+85
View File
@@ -0,0 +1,85 @@
name: Nightly Release
# Publishes the nightly prerelease channel: a dated prerelease versioned
# NEXT-MINOR over the newest stable tag
# (v<stable.minor+1>.0-nightly.<YYYYMMDDHHMMSS>), so a nightly always
# outversions every stable build of the current line and loses to the
# next stable minor — which is exactly how the electron-updater channel
# switch works in both directions (see hermes_cli/update_channel.py).
# Second-precision suffix: manual dispatches can publish several
# nightlies per day; the scheduled run publishes at most one because
# release.py skips when HEAD has no new commits since the last nightly.
#
# ALL of it lives in scripts/release.py --nightly: the tag math, the
# skip gate, the draft release, and the dispatch of the desktop build.
# This workflow only supplies a schedule and credentials, which is why
# `release.py --nightly --publish` by hand does exactly what the cron
# does — same tag, same draft, same build, same auto-publish.
#
# The desktop build uploads the installers to the Cloudflare R2 release
# bucket and publishes the draft when the whole matrix is green. A failed
# matrix leaves a draft: inspectable, re-runnable, and unreachable by
# users. The prune job deletes both the GitHub releases+tags and the R2
# nightly objects older than 14 days.
#
# schedule: is the ONE sanctioned time trigger in this repo (nightly
# exemption). Everything else stays event-driven.
on:
schedule:
- cron: '0 8 * * *' # daily, 0300 EST / 0800 UTC, middle of the night when most stuff will be moving less
workflow_dispatch: {}
permissions:
contents: write # tag push + release create/delete for prune
actions: write # release.py dispatches the desktop build
concurrency:
group: nightly-release
cancel-in-progress: false
jobs:
tag:
name: Tag, draft, and start the nightly build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# The changelog walks commits since the previous nightly tag
fetch-depth: 0
# the tag math reads the full tag list
fetch-tags: true
- name: Tag and draft
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
python3 scripts/release.py --nightly --publish --remote origin
prune:
name: Prune nightlies older than 14 days
runs-on: ubuntu-24.04
needs: tag
environment: release-signing
# Prune even when there was nothing to tag today. The desktop build
# runs in its own workflow run now, so this no longer waits on it —
# the window is 14 days, far wider than any build.
if: always() && needs.tag.result != 'cancelled'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Prune
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release.py --prune-nightlies --publish --remote origin
- name: Prune R2 nightly objects
env:
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }}
run: node scripts/r2-release.mjs prune-nightlies --keep-days 14
+124
View File
@@ -0,0 +1,124 @@
name: PM Bundle
# Stages the self-contained agent payload for each (os, arch) target with
# `hermes pm bundle`: repo snapshot + tool store + facts + a relocatable
# venv built on the staged python-build-standalone interpreter. Each
# payload is smoke-tested on its native runner — the staged interpreter
# boots hermes_cli out of the payload with no network and no PYTHONPATH —
# as a pre-merge check for the pm tooling itself.
#
# Staging is native per target (no cross-target wheel tables anywhere):
# the runner IS the target. pm's arch guard fails the job if any staged
# mismatches, before anything ships.
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref to bundle (default: the triggering ref)'
required: false
type: string
pull_request:
paths:
- 'pm/**'
- 'uv.lock'
- 'pyproject.toml'
- '.github/workflows/pm-bundle.yml'
permissions:
contents: read
concurrency:
group: pm-bundle-${{ github.ref }}
cancel-in-progress: true
jobs:
bundle:
name: bundle ${{ matrix.target.label }}
runs-on: ${{ matrix.target.runner }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
target:
- label: linux-x64
runner: ubuntu-24.04
- label: linux-arm64
runner: ubuntu-24.04-arm
- label: darwin-arm64
runner: macos-15
- label: darwin-x64
runner: macos-15-intel
- label: win32-x64
runner: windows-2025
- label: win32-arm64
runner: windows-11-arm
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
fetch-tags: true
# The host uv only bootstraps a python to run pm itself; every
# payload tool (uv included) is staged by pm from pm/lock.json.
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0
with:
enable-cache: false
# One cache for the pm store: keyed on the lockfile, so a pin bump
# rotates it. pm verifies every restored entry against the lock
# (hash-named fetches, verify() on entries) — the cache is an
# optimization, never a proof.
- name: Cache pm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: build/agent-payload/tools
key: pm-store-${{ matrix.target.label }}-${{ hashFiles('pm/lock.json', 'uv.lock') }}
# win32-arm64 builds cryptography from sdist (no win_arm64 wheel);
# its Rust core links OpenSSL statically from vcpkg.
- name: Cache vcpkg OpenSSL (arm64)
if: matrix.target.label == 'win32-arm64'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: C:\vcpkg\installed\arm64-windows-static-md
key: vcpkg-openssl-arm64-windows-static-md-${{ runner.os }}
- name: Install OpenSSL (arm64)
if: matrix.target.label == 'win32-arm64'
shell: bash
run: |
if [ ! -f "/c/vcpkg/installed/arm64-windows-static-md/lib/libcrypto.lib" ]; then
"$VCPKG_INSTALLATION_ROOT/vcpkg" install openssl:arm64-windows-static-md
fi
{
printf 'OPENSSL_DIR=C:\\vcpkg\\installed\\arm64-windows-static-md\n'
printf 'OPENSSL_STATIC=1\n'
} >> "$GITHUB_ENV"
- name: Stage the payload
shell: bash
env:
# Windows runners default to cp1252; pm prints UTF-8 (✓/✗).
PYTHONUTF8: '1'
# ring/obstore's C sources need clang, not MSVC, on windows-arm64.
# Scoped to the cargo target triple so every other sdist keeps MSVC.
CC_aarch64_pc_windows_msvc: ${{ matrix.target.label == 'win32-arm64' && 'clang' || '' }}
run: |
# The bootstrap interpreter tracks the payload interpreter's
# minor version — one authority (pm/lock.json), no drift.
PYVER=$(python3 -c "import json; v=json.load(open('pm/lock.json'))['packages']['python']['version']; print('.'.join(v.split('+')[0].split('.')[:2]))" 2>/dev/null || python -c "import json; v=json.load(open('pm/lock.json'))['packages']['python']['version']; print('.'.join(v.split('+')[0].split('.')[:2]))")
# Archive what actions/checkout actually checked out. On
# pull_request events github.sha names a merge commit that a
# force-push invalidates mid-run ("not a tree object").
uv run --no-project --python "$PYVER" python -m pm.cli bundle \
--out build/agent-payload \
--ref HEAD
# The payload must boot with nothing from this checkout: the staged
# venv's interpreter, cwd at the staged REPO (the desktop spawn
# convention — sys.path[0] from cwd survives relocation where the
# editable install's absolute path would not), no PYTHONPATH.
- name: Smoke test the payload
shell: bash
run: bash scripts/smoke-payload.sh build/agent-payload
+10 -16
View File
@@ -34,28 +34,22 @@ class OTLPUnavailable(RuntimeError):
"""Raised when the optional OpenTelemetry SDK isn't installed."""
def _require_sdk(*, auto_install: bool = True, prompt: bool = True):
def _require_sdk(*, auto_install: bool = True):
"""Import the OTel SDK, lazily installing it on first use if needed.
Routes through tools.lazy_deps (feature 'export.otlp') so a missing SDK
Routes through pm.ensure_import('otlp') so a missing SDK
triggers the standard venv install flow — same as every other optional
backend — gated by security.allow_lazy_installs and TTY-prompted. Falls back
to OTLPUnavailable (with a manual install hint) when the SDK can't be made
backend — gated by security.allow_lazy_installs. Falls back to
OTLPUnavailable (with a manual install hint) when the SDK can't be made
importable (lazy installs disabled, install failed, or auto_install=False).
``auto_install``: attempt the lazy install when missing (default True).
``prompt``: ask before installing when interactive (default True); pass
False from non-interactive contexts like the continuous streamer.
"""
if auto_install:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("export.otlp", prompt=prompt)
except ImportError:
pass # lazy_deps unavailable — fall through to the import attempt
from pm import ensure_import as _lazy_ensure
_lazy_ensure("otlp")
except Exception:
# FeatureUnavailable (lazy installs disabled / declined / failed) —
# fall through; the import below raises OTLPUnavailable with the hint.
# Any lazy-install failure — fall through; the import below
# raises OTLPUnavailable with the hint.
pass
try:
from opentelemetry.sdk.trace import TracerProvider
@@ -240,7 +234,7 @@ def start_streaming(
``event_filter`` scopes the exporter to its plane, e.g. gateway-health
export, so enabling one plane cannot silently export unrelated events.
Non-interactive context (startup): attempts a lazy install with prompt=False
Non-interactive context (startup): attempts a lazy install
so a configured-but-missing SDK is installed once (gated by
security.allow_lazy_installs), then streams. If it still can't load, logs and
no-ops — never blocks or raises into startup.
@@ -248,7 +242,7 @@ def start_streaming(
if not is_enabled(config):
return None
try:
_require_sdk(prompt=False)
_require_sdk()
except OTLPUnavailable:
logger.warning("monitoring.export.otlp.enabled but the OTel SDK could not "
"be installed/imported; install 'hermes-agent[otlp]'")
+123
View File
@@ -0,0 +1,123 @@
# Building the Desktop Installers
This document tells you how the bundled desktop installers are built, and how
to build and test one on your machine. For the app architecture, read
`AGENTS.md` in this directory.
## What a bundle is
A bundled installer contains the full Hermes runtime. The user installs one
file and gets everything. Nothing downloads at first launch.
The installer contains:
- The Electron app (the chat surface).
- The agent source tree at the release tag, without `.git`.
- `uv` and a CPython interpreter for the target architecture.
- A ready `site-packages` tree, built from the lockfile.
- A Node runtime and the prebuilt JS surfaces (ui-tui, dashboard SPA).
- An install stamp (`install-stamp.json`) recording the tag, the commit, the
distribution, the update mechanism, and where the managed runtime dir
lives.
The app runs the backend directly from its own resources. This is the
"bundled" install axis, built by `pm bundle` against the `pm/lock.json` pin
table — every managed tool (uv, python, node, npm, ripgrep, git, chromium,
cua-driver) is digest-pinned and staged at build time.
## The installer for each platform
| Platform | Artifact | Notes |
|---|---|---|
| Windows | MSIX `.msix` / `.msixbundle` | The shipping artifact. Signed with Azure Trusted Signing. Out-of-store installs update via the OS App Installer (.appinstaller source; the app checks + prompts, the OS applies). Store-submission builds (HERMES_DESKTOP_VARIANT=store) use the Partner Center identity and update via the Store. |
| macOS | `.dmg` | Signed and notarized when the `APPLE_*` / `CSC_*` secrets are set. Updated via electron-updater against the `latest-mac.yml` feed. |
| Linux | unpacked / AppImage | Unsigned. |
NSIS is intentionally dead (D1 decision): Windows ships MSIX only.
## How the build works
One script drives the whole build:
```
node scripts/build-bundled-desktop.mjs --tag=vX.Y.Z
```
The script always runs every step:
1. **Gate the toolchain.** The host `node` and `npm` must satisfy
`package.json` engines, and the toolchain pins resolve from
`pm/lock.json` (the "Resolve toolchain pins" CI step). The payload embeds
these exact pinned versions, so gate == embed.
2. **Build the JS surfaces.** ui-tui (with hermes-ink) and the dashboard SPA.
3. **Build the desktop app.** `npm run build` in `apps/desktop`: vite,
electron-main bundle, native deps, then payload staging.
4. **Stage the agent payload** (`pm bundle`). This snapshots the repo at the
tag with `git archive`, copies the prebuilt JS surfaces in, installs the
pinned CPython and `site-packages`, stages the pinned managed tools, and
writes `manifest.json` plus the install stamp. Each staged binary must
prove the target architecture in its own version banner. A wrong-
architecture binary fails the build.
5. **Package with electron-builder.** MSIX on Windows, DMG on macOS.
Payload staging stays dormant unless `HERMES_DESKTOP_VARIANT=bundled` is
set. The build script sets it. A normal `npm run dev` or `npm run pack`
without the script does not stage payloads.
## Code signing (Windows)
Signing turns on when the `AZURE_SIGN_*` environment variables are set:
```
AZURE_SIGN_ENDPOINT https://cus.codesigning.azure.net
AZURE_SIGN_ACCOUNT codesign2
AZURE_SIGN_PROFILE hermesagent
AZURE_SIGN_PUBLISHER CN=Nous Research Inc., ...
AZURE_CLIENT_ID (the OIDC app id)
```
`electron-builder.config.cjs` reads these variables and composes the
`win.sign` configuration itself. Do not pass the values as `-c` arguments:
the publisher name contains spaces, and spaces do not survive the cmd.exe
argument hop. Signing runs through `scripts/sign-msix.mjs`, a plain hook
that signs ONLY the .msix package: Windows validates the package signature
(AppxSignature.p7x over AppxBlockMap.xml), and inner binaries are covered
by the block-map hashes — per-file Authenticode is neither required nor
validated.
On win32, `scripts/after-pack.mjs` runs `sanitize-pe-signatures.mjs` before
the MSIX pack: python-build-standalone's `llvm-strip` can leave dangling PE
certificate tables, and one dangling table fails the whole package with
0x800700C1. This is a hard-fail step, not best-effort.
## The MSIX manifest
`assets/msix-manifest.xml` is the stock app-builder-lib template plus the
`uap5`/`desktop4` namespaces needed by the CLI execution aliases, and the
`uap3` fragment (declared on its own root) that registers the app as a
Windows Copilot hardware key provider. The extensions file
(`build/msix-extensions.xml`) is generated at config-require time by
`writeMsixExtensions()` in `electron-builder.config.cjs`. Keep the manifest
in sync with app-builder-lib when electron-builder bumps.
## Code signing (macOS)
The macOS build signs and notarizes with electron-builder's builtin
notarization when the `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` /
`APPLE_TEAM_ID` secrets are present. The `sign-nested-chromium` path is dead
(the browser ships in the payload; nested signing is handled by the
electron-builder signing pass).
## Local build
```
cd apps/desktop
npm install
npm run dist:win:msix # or dist:mac / dist:linux
```
For a fast dev loop without payload staging:
```
npm run dev # vite + electron against a dev backend
```
+1 -1
View File
@@ -78,7 +78,7 @@ npm run dev:fake-boot # exercise the startup overlay with deterministic delays
```bash
npm run dist:mac # DMG + zip
npm run dist:win # NSIS + MSI
npm run dist:win # MSIX
npm run dist:linux # AppImage + deb + rpm
npm run pack # unpacked app under release/ (no installer)
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Custom MSIX manifest for Hermes desktop.
Forked from app-builder-lib's templates/msix/appxmanifest.xml. The PM
payload console-script aliases are uap5 extensions. The package root
must declare uap5 and list it in IgnorableNamespaces.
Keep this template in sync with app-builder-lib when electron-builder
changes. The only intended delta is the uap5 namespace declaration and
IgnorableNamespaces entry.
-->
<!--suppress XmlUnusedNamespaceDeclaration -->
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap5 uap10 desktop4 desktop6">
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
<Identity Name="${identityName}"
ProcessorArchitecture="${arch}"
Publisher='${publisher}'
Version="${version}" />
<Properties>
<DisplayName>${displayName}</DisplayName>
<PublisherDisplayName>${publisherDisplayName}</PublisherDisplayName>
<Description>${description}</Description>
<Logo>${logo}</Logo>
${packageIntegrity}
</Properties>
<Resources>
${resourceLanguages}
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="${minVersion}" MaxVersionTested="${maxVersionTested}" />
</Dependencies>
${capabilities}
<Applications>
<Application Id="${applicationId}" Executable="${executable}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
BackgroundColor="${backgroundColor}"
DisplayName="${displayName}"
Square150x150Logo="${square150x150Logo}"
Square44x44Logo="${square44x44Logo}"
Description="${description}">
${defaultTile}
${lockScreen}
${splashScreen}
</uap:VisualElements>
${extensions}
</Application>
</Applications>
</Package>
@@ -7,6 +7,10 @@ export interface ActiveRuntimeState {
hasValidMarker: boolean
shouldUseActiveRuntime: boolean
usabilityReason: 'usable' | 'unusable'
/** The canonical-root install stamp (written by the bootstrap), when the
* active runtime was desktop-installed. Populated by the caller
* (main.ts activeRuntimeState); undefined when never set. */
canonicalInstallStamp?: { source?: unknown; commit?: unknown; branch?: unknown } | null
}
export function hasValidBootstrapMarker(
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
checkAppInstallerUpdate,
PLACEHOLDER_FEED_BASE_URL,
triggerAppInstallerUpdate,
win32AppInstallerFeedPath
} from './app-updater'
// ── feed hosting + paths ────────────────────────────────────────────
test('the feed base URL placeholder is a documented dead end', () => {
// Overridden by `updates.desktop_feed_base_url` (read inline in main.ts);
// the placeholder itself must never resolve to a real host.
assert.equal(PLACEHOLDER_FEED_BASE_URL, 'https://updates.invalid/hermes-desktop')
})
test('win32 App Installer feed paths are per-channel and per-variant', () => {
assert.equal(win32AppInstallerFeedPath('stable', false), 'win32/stable/')
assert.equal(win32AppInstallerFeedPath('nightly', false), 'win32/nightly/')
assert.equal(win32AppInstallerFeedPath('stable', true), 'win32/light/stable/')
assert.equal(win32AppInstallerFeedPath('nightly', true), 'win32/light/nightly/')
})
// ── win32 arm (OS App Installer checker + trigger) ─────────────────
function fakePayloadRunner(stdout: string, code = 0) {
const calls: string[] = []
return {
runner: {
python: 'C:\\payload\\tools\\cpython\\python.exe',
script: 'C:\\payload\\scripts\\check-appinstaller-update.py',
run: async (python: string, script: string) => {
calls.push(`${python} ${script}`)
return { code, stdout }
}
} as any,
calls
}
}
test('win32 check reports available when the OS says a newer package exists', async () => {
const { runner } = fakePayloadRunner(JSON.stringify({ available: true, availability: 'Available' }))
const result = await checkAppInstallerUpdate(runner)
assert.equal(result.available, true)
assert.equal(result.availability, 'Available')
})
test('win32 check reports not-available cleanly', async () => {
const { runner } = fakePayloadRunner(JSON.stringify({ available: false, availability: 'NoApplicableUpdate' }))
const result = await checkAppInstallerUpdate(runner)
assert.equal(result.available, false)
})
test('win32 check surfaces an unknown verdict (missing winrt) without crashing', async () => {
const { runner } = fakePayloadRunner(JSON.stringify({ available: null, error: 'winrt import failed' }), 1)
const result = await checkAppInstallerUpdate(runner)
assert.equal(result.available, null)
assert.match(result.error || '', /winrt import failed/)
})
test('win32 trigger opens ms-appinstaller with the channel .appinstaller after teardown', async () => {
const calls: string[] = []
const shell = {
openExternal: async (url: string) => void calls.push(`open:${url}`)
}
const result = await triggerAppInstallerUpdate(
'https://updates.example.com/',
'stable',
false,
shell as any,
() => void calls.push('teardown')
)
assert.equal(result.ok, true)
assert.deepEqual(calls, [
'teardown',
'open:ms-appinstaller:?source=https%3A%2F%2Fupdates.example.com%2Fwin32%2Fstable%2Fstable.appinstaller'
])
})
test('win32 trigger light+nightly targets the light nightly feed dir', async () => {
const calls: string[] = []
const shell = {
openExternal: async (url: string) => void calls.push(url)
}
await triggerAppInstallerUpdate('https://updates.example.com', 'nightly', true, shell as any)
assert.equal(calls[0], 'ms-appinstaller:?source=https%3A%2F%2Fupdates.example.com%2Fwin32%2Flight%2Fnightly%2Fnightly.appinstaller')
})
+137
View File
@@ -0,0 +1,137 @@
// app-updater.ts — the win32 update arm for bundled desktop installs.
//
// win32 — the OS App Installer owns the apply. The package was installed
// from an .appinstaller, which registered the feed URI as the
// package's update source; the OS checks it and swaps the package
// wholesale. The app's only job is the checker: ask the OS whether
// an update is available (via the bundled payload python's winrt),
// show its own prompt, run graceful teardown, then trigger
// ms-appinstaller: and quit. Installations that Windows manages
// for us — Microsoft Store deployments (process.windowsStore) and
// stamps whose updateMechanism is 'external' — get NO in-app
// updater at all: the store/steward owns the update loop.
//
// Source installs never reach this module. The callers gate on the install
// stamp first and fall through to the git-based update path.
//
// NOTE: the darwin electron-updater arm (and its gate/channel/feed helpers —
// shouldUseAppUpdater, selectUpdaterArm, resolveUpdaterChannel,
// resolveFeedBaseUrl, feedSelection, describeFeedCheck) was ripped out in
// wt/darwin-updater: main.ts reimplemented the gate/channel/feed inline, so
// the module surface was test-only. To add macOS in-app updates back, restore
// the arm from git history (this file @ the parent of that commit) together
// with the `electron-updater` dependency and its test block — see the
// add-back plan in the wt/darwin-updater commit message.
//
// The win32 helpers are pure so vitest covers them; the impure pieces
// (electron shell, payload python) are injected.
// ─── feed hosting ───────────────────────────────────────────────────────────
/**
* PLACEHOLDER — the default base URL of the desktop release feeds.
*
* W3 (R2 hosting) supplies the real value; until it lands, this default is
* a documented dead end that config overrides (`updates.desktop_feed_base_url`
* in config.yaml — read inline by main.ts). Layout under the base:
*
* <base>/win32/<channel>/ — App Installer feed dir (.appinstaller
* + .msixbundle) for out-of-store MSIX.
*
* `<channel>` is 'stable' or 'nightly'.
*/
export const PLACEHOLDER_FEED_BASE_URL = 'https://updates.invalid/hermes-desktop'
/**
* The App Installer feed dir for a channel. The .appinstaller for a channel
* lives under this dir; the OS re-reads it on launch (HoursBetweenUpdateChecks)
* and swaps the bundle when a newer version is there. The light variant feeds
* from its own subtree so the two variants can never serve each other's
* packages.
*/
export function win32AppInstallerFeedPath(
channel: 'stable' | 'nightly',
light: boolean
): string {
const variant = light ? 'light/' : ''
return `win32/${variant}${channel}/`
}
// ─── win32 arm (OS App Installer checker + trigger) ────────────────────────
/** The result of an App Installer update check. */
export interface AppInstallerCheck {
/** null when the check could not run (no winrt, not packaged, error). */
available: boolean | null
/** Human-readable availability string from the OS, when reported. */
availability?: string
error?: string
}
/**
* The payload-python invocation the win32 arm needs. Injectable so vitest
* covers the arm without a payload.
*/
export interface PayloadPythonRunner {
/** Absolute path to the bundled payload python (tools/<entry>/python.exe). */
python: string
/** The checker script's absolute path. */
script: string
/** Run the script; resolve with {code, stdout}. */
run: (python: string, script: string) => Promise<{ code: number; stdout: string }>
}
/**
* win32 arm: ask the OS whether an App Installer update is available, via
* the bundled payload python's winrt (PackageManager.CheckPackageUpdateAvailabilityAsync).
* The OS compares the package's registered .appinstaller source against the
* installed version; it does NOT download anything.
*/
export async function checkAppInstallerUpdate(
runner: PayloadPythonRunner
): Promise<AppInstallerCheck> {
const { code, stdout } = await runner.run(runner.python, runner.script)
const text = stdout.trim()
let parsed: { available?: boolean | null; availability?: string; error?: string; reason?: string } | null = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
if (parsed && typeof parsed.available === 'boolean') {
return { available: parsed.available, availability: parsed.availability, error: parsed.error }
}
if (parsed && parsed.available === null) {
return { available: null, error: parsed.error || 'checker returned unknown' }
}
if (code !== 0) {
return { available: null, error: parsed?.error || `checker exited ${code}` }
}
return { available: null, error: 'checker returned no availability' }
}
/**
* win32 arm: trigger the OS App Installer to apply the update and quit.
* `ms-appinstaller:?source=<feed .appinstaller URL>` makes the OS re-read the
* package's update source and install the newer bundle; the app then exits so
* the package swap is not contested. `beforeInstall` runs first (backend
* teardown while the process is still alive).
*/
export async function triggerAppInstallerUpdate(
feedBaseUrl: string,
channel: 'stable' | 'nightly',
light: boolean,
shell: { openExternal: (url: string) => Promise<void> },
beforeInstall?: () => void | Promise<void>
): Promise<{ ok: true }> {
if (beforeInstall) {
await beforeInstall()
}
const appinstallerUrl =
`${feedBaseUrl.replace(/\/+$/, '')}/${win32AppInstallerFeedPath(channel, light)}${channel}.appinstaller`
await shell.openExternal(`ms-appinstaller:?source=${encodeURIComponent(appinstallerUrl)}`)
return { ok: true }
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { routeDeepLink } from './deep-link-route'
describe('routeDeepLink', () => {
it('summons quick entry for the Copilot key press', () => {
expect(routeDeepLink('copilot-key', 'start')).toBe('quick-entry')
})
it('ignores the Copilot key release so a tap does not undo the summon', () => {
expect(routeDeepLink('copilot-key', 'stop')).toBe('ignore')
})
it('ignores unknown copilot-key paths instead of leaking them to the renderer', () => {
expect(routeDeepLink('copilot-key', '')).toBe('ignore')
expect(routeDeepLink('copilot-key', 'toggle')).toBe('ignore')
})
it('routes every other deep link to the renderer', () => {
expect(routeDeepLink('blueprint', 'morning-brief')).toBe('renderer')
expect(routeDeepLink('', '')).toBe('renderer')
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* Where a hermes:// deep link is handled. Pure — main.ts routes on the
* result, tests assert the contract.
*
* 'quick-entry' — summon the ephemeral quick-entry popup in the main
* process. Used by the Windows Copilot hardware key: the MSIX manifest
* fragment (generated by electron-builder.config.cjs) registers the app
* as a copilotkeyprovider whose press activates
* <scheme>://copilot-key/start.
* A hardware key wants the lightweight composer, not the full window.
* 'ignore' — activation noise that must not reach the renderer. The
* Copilot key's press-and-hold RELEASE fires copilot-key/stop; a tap
* fires start+stop nearly together, so acting on stop would undo the
* summon. Unknown copilot-key paths land here too.
* 'renderer' — everything else: focus the main window and deliver the
* payload to the renderer's deep-link listener (blueprint links etc.).
*/
export type DeepLinkRoute = 'ignore' | 'quick-entry' | 'renderer'
export function routeDeepLink(kind: string, name: string): DeepLinkRoute {
if (kind === 'copilot-key') {
return name === 'start' ? 'quick-entry' : 'ignore'
}
return 'renderer'
}
@@ -19,12 +19,14 @@
*
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
* installed binary to match ``electronVersion`` / ``electronDist``), and
* 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry
* all agree — so ``npm ci`` installs exactly what the build packages.
* 2. the dependency, electron-builder.config.cjs ``electronVersion``, and the
* resolved lockfile entry all agree — so ``npm ci`` installs exactly what
* the build packages.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { test } from 'vitest'
@@ -67,18 +69,18 @@ test('electron dependency is exactly pinned', () => {
)
})
test('electron dependency matches build.electronVersion', () => {
const pkg = desktopPkg()
const spec = electronSpec(pkg)
const build = (pkg.build ?? {}) as Record<string, unknown>
const builderVersion = build.electronVersion as string | undefined
assert.ok(builderVersion, 'build.electronVersion is missing')
test('electron dependency matches electron-builder.config.cjs', () => {
const spec = electronSpec(desktopPkg())
const require = createRequire(import.meta.url)
const config = require('../electron-builder.config.cjs') as { electronVersion?: string }
const builderVersion = config.electronVersion
assert.ok(builderVersion, 'electron-builder.config.cjs electronVersion is missing')
assert.equal(
spec,
builderVersion,
`electron dependency ("${spec}") must equal build.electronVersion ` +
`("${builderVersion}"); otherwise electron-builder packages a different ` +
'version than npm installs into electronDist.'
`electron dependency ("${spec}") must equal electron-builder.config.cjs ` +
`electronVersion ("${builderVersion}"); otherwise electron-builder packages ` +
'a different version than npm installs.'
)
})
@@ -14,31 +14,67 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
allowedUninstallModes,
buildPosixCleanupScript,
buildWindowsCleanupScript,
installKindAllowsCodeRemoval,
modeRemovesAgent,
modeRemovesUserData,
nativeRemovalInstructions,
resolveInstallKind,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
} from './desktop-uninstall'
// --- uninstallArgsForMode ---
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
})
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
})
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
// --- resolveInstallKind / allowedUninstallModes / nativeRemovalInstructions ---
test('resolveInstallKind reads the stamp: distribution nix wins, payload kind means bundled', () => {
assert.equal(resolveInstallKind({ distribution: 'nix' }), 'nix')
assert.equal(resolveInstallKind({ source: 'nix' }), 'nix')
// distribution beats payload: a nix stamp never becomes 'bundled'.
assert.equal(resolveInstallKind({ distribution: 'nix', payload: 'bundled' }), 'nix')
assert.equal(resolveInstallKind({ distribution: 'desktop-app', payload: 'bundled' }), 'bundled')
assert.equal(resolveInstallKind({ payload: 'bundled' }), 'bundled')
// light artifacts have no agent code either — same managed flow.
assert.equal(resolveInstallKind({ payload: 'light' }), 'bundled')
// bootstrap / no stamp facts at all → the classic source/installer flow.
assert.equal(resolveInstallKind({ payload: 'bootstrap' }), 'standard')
assert.equal(resolveInstallKind({}), 'standard')
assert.equal(resolveInstallKind(), 'standard')
})
test('only standard installs may remove code; managed kinds get data-only', () => {
assert.equal(installKindAllowsCodeRemoval('standard'), true)
assert.equal(installKindAllowsCodeRemoval('nix'), false)
assert.equal(installKindAllowsCodeRemoval('bundled'), false)
assert.deepEqual(allowedUninstallModes('standard'), ['gui', 'lite', 'full'])
assert.deepEqual(allowedUninstallModes('nix'), ['data'])
assert.deepEqual(allowedUninstallModes('bundled'), ['data'])
})
test('nativeRemovalInstructions names the steward per kind and OS', () => {
assert.match(nativeRemovalInstructions('nix', 'linux'), /installed by Nix/)
assert.match(nativeRemovalInstructions('nix', 'darwin'), /flake or profile/)
assert.match(nativeRemovalInstructions('bundled', 'win32'), /Installed apps/)
assert.match(nativeRemovalInstructions('bundled', 'darwin'), /Trash/)
assert.match(
nativeRemovalInstructions('bundled', 'linux', '/home/x/Apps/Hermes.AppImage'),
/\/home\/x\/Apps\/Hermes\.AppImage/
)
assert.match(
nativeRemovalInstructions('bundled', 'linux', '/opt/hermes/linux-unpacked'),
/app directory at \/opt\/hermes\/linux-unpacked/
)
assert.match(nativeRemovalInstructions('bundled', 'linux'), /wherever you saved it/)
})
// --- modeRemovesAgent / modeRemovesUserData ---
@@ -47,10 +83,12 @@ test('mode predicates classify what each mode removes', () => {
assert.equal(modeRemovesAgent('gui'), false)
assert.equal(modeRemovesAgent('lite'), true)
assert.equal(modeRemovesAgent('full'), true)
assert.equal(modeRemovesAgent('data'), false)
assert.equal(modeRemovesUserData('gui'), false)
assert.equal(modeRemovesUserData('lite'), false)
assert.equal(modeRemovesUserData('full'), true)
assert.equal(modeRemovesUserData('data'), true)
})
// --- resolveRemovableAppPath ---
+107 -7
View File
@@ -28,7 +28,102 @@
import path from 'node:path'
const UNINSTALL_MODES = ['gui', 'lite', 'full']
import type { ArtifactKind } from './install-stamp'
const UNINSTALL_MODES = ['gui', 'lite', 'full', 'data']
// How this desktop app got onto the machine, read from the install stamp
// (install-stamp.json) that every packager writes:
// 'nix' — a Nix build (stamp distribution 'nix'). The store is
// immutable and the install is owned by Nix tooling, so the
// app must not remove any code, its own bundle included.
// 'bundled' — an embedded artifact (agent payload in resources; the
// stamp has payload:true). There is no agent venv under
// HERMES_HOME, and the OS owns app removal (Apps & Features
// / Trash / delete the AppImage).
// 'standard' — everything else: the git-clone install the desktop
// installer bootstraps, or a `hermes desktop` source build.
// The classic script flow (venv python + rm the bundle) works.
//
// Only 'standard' installs may remove code. 'nix' and 'bundled' installs
// may only remove user data (mode 'data') — the app itself is removed the
// native way, per nativeRemovalInstructions().
const INSTALL_KINDS = ['nix', 'bundled', 'standard']
/**
* Classify the install from the stamp. Pure so it can be unit-tested:
* callers pass the stamp fields (`distribution`, `source`, `payload`).
* `distribution` is authoritative; `source` is the schema-1 fallback.
* The 'bundled' and 'light' artifact kinds both classify as the managed
* 'bundled' flow — neither has agent code the app may remove, and the OS
* owns app removal.
*/
function resolveInstallKind({
distribution,
source,
payload = 'bootstrap'
}: { distribution?: string | null; source?: string | null; payload?: ArtifactKind } = {}) {
if (distribution === 'nix' || source === 'nix') {
return 'nix'
}
if (payload === 'bundled' || payload === 'light') {
return 'bundled'
}
return 'standard'
}
/** True when this install kind lets the app remove code (agent / bundle). */
function installKindAllowsCodeRemoval(kind) {
return kind === 'standard'
}
/**
* The modes the uninstall UI may offer for an install kind. Every kind can
* remove user data. Only 'standard' may also remove code — there the 'full'
* mode already covers data, so 'data' alone is not offered. Managed installs
* (nix, bundled) get exactly one destructive action — remove user data —
* with app removal handed to the steward via nativeRemovalInstructions().
*/
function allowedUninstallModes(kind) {
return installKindAllowsCodeRemoval(kind) ? ['gui', 'lite', 'full'] : ['data']
}
/**
* Human instructions for removing the app itself the native way. Used when
* the install kind forbids code removal: Windows owns the bundled app
* through Apps & Features, macOS through the Trash, a Linux AppImage is a
* single file the user placed somewhere, and a Nix install belongs to the
* flake / profile that made it. `appPath` is the resolveRemovableAppPath()
* result (the AppImage path on Linux), used only to name the exact file.
*/
function nativeRemovalInstructions(kind, platform, appPath = null) {
if (kind === 'nix') {
return (
'This Hermes desktop app was installed by Nix. Uninstall it the same way you installed it: ' +
'remove hermes-agent from your flake or profile, then rebuild.'
)
}
if (platform === 'win32') {
return 'To uninstall, go to Windows Settings → Apps → Installed apps.'
}
if (platform === 'darwin') {
return 'Quit the app and drag Hermes.app from Applications to the Trash.'
}
if (appPath && /\.appimage$/i.test(String(appPath))) {
return `Delete the AppImage file at ${appPath}.`
}
if (appPath) {
return `Delete the app directory at ${appPath}.`
}
return 'Delete the Hermes AppImage (or app directory) from wherever you saved it.'
}
/**
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
@@ -37,7 +132,7 @@ const UNINSTALL_MODES = ['gui', 'lite', 'full']
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
* Throws on an unknown mode so a typo can't silently become a full wipe.
*/
function uninstallArgsForMode(mode) {
function uninstallArgsForMode(mode: string) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
@@ -45,14 +140,14 @@ function uninstallArgsForMode(mode) {
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
/** True when `mode` removes the agent (lite/full), false for gui-only. */
function modeRemovesAgent(mode) {
/** True when `mode` removes the agent code (lite/full), false otherwise. */
function modeRemovesAgent(mode: string) {
return mode === 'lite' || mode === 'full'
}
/** True when `mode` removes user data (full only). */
function modeRemovesUserData(mode) {
return mode === 'full'
/** True when `mode` removes user data (full and data). */
function modeRemovesUserData(mode: string) {
return mode === 'full' || mode === 'data'
}
/**
@@ -257,10 +352,15 @@ function buildWindowsCleanupScript({
}
export {
allowedUninstallModes,
buildPosixCleanupScript,
buildWindowsCleanupScript,
INSTALL_KINDS,
installKindAllowsCodeRemoval,
modeRemovesAgent,
modeRemovesUserData,
nativeRemovalInstructions,
resolveInstallKind,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
+136
View File
@@ -0,0 +1,136 @@
/**
* Tests for electron/external-open.ts — the single route every external URL
* open funnels through. All I/O is injected, so the "open failed → notify"
* behavior is asserted without loading electron. Run with the electron test
* script that runs electron/*.test.ts (same pattern as native-oauth-login).
*/
import assert from 'node:assert/strict'
import type { ChildProcess } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { test } from 'vitest'
import { type ExternalOpenDeps, openExternalUrl } from './external-open'
function makeDeps(overrides: Partial<ExternalOpenDeps> = {}) {
const calls = {
opened: [] as string[],
fileOpened: [] as string[],
notified: [] as Array<[string, string]>,
logged: [] as string[]
}
const deps: ExternalOpenDeps = {
isWsl: false,
spawn: () => {
throw new Error('not used')
},
openExternal: async url => {
calls.opened.push(url)
},
openFile: async raw => {
calls.fileOpened.push(raw)
},
notifyFailure: (url, message) => calls.notified.push([url, message]),
log: line => calls.logged.push(line),
...overrides
}
return { deps, calls }
}
test('resolves ok and opens when openExternal succeeds', async () => {
const { deps, calls } = makeDeps()
const result = await openExternalUrl('https://example.com/x', deps)
assert.deepEqual(result, { ok: true })
assert.deepEqual(calls.opened, ['https://example.com/x'])
assert.equal(calls.notified.length, 0)
})
test('notifies and resolves failed when openExternal rejects', async () => {
const { deps, calls } = makeDeps({
openExternal: async () => {
throw new Error('no method available for opening')
}
})
const result = await openExternalUrl('https://example.com', deps)
assert.deepEqual(result, {
ok: false,
reason: 'failed',
message: 'no method available for opening'
})
assert.deepEqual(calls.notified, [['https://example.com/', 'no method available for opening']])
assert.ok(calls.logged.some(line => line.includes('openExternal failed')))
})
test('resolves invalid for a URL the route does not open, with no notify', async () => {
const { deps, calls } = makeDeps()
for (const url of ['', 'not a url', 'ftp://x.com']) {
const result = await openExternalUrl(url, deps)
assert.equal(result.ok, false)
if (result.ok === false) {
assert.equal(result.reason, 'invalid')
}
}
assert.equal(calls.opened.length, 0)
assert.equal(calls.notified.length, 0)
})
test('dispatches file:// URLs to openFile', async () => {
const { deps, calls } = makeDeps()
const result = await openExternalUrl('file:///C:/x.html', deps)
assert.deepEqual(result, { ok: true })
assert.deepEqual(calls.fileOpened, ['file:///C:/x.html'])
})
test('wsl: spawns cmd.exe and resolves ok on the happy path', async () => {
const spawned: string[] = []
const proc = new EventEmitter() as unknown as ChildProcess
const { deps } = makeDeps({
isWsl: true,
spawn: (cmd, args) => {
spawned.push(cmd, ...args)
return proc
}
})
const result = await openExternalUrl('https://example.com', deps)
assert.deepEqual(result, { ok: true })
assert.equal(spawned[0], 'cmd.exe')
assert.ok(spawned.some(arg => arg === 'https://example.com/'))
})
test('wsl: falls back to openExternal and notifies when cmd.exe fails to spawn', async () => {
const proc = new EventEmitter() as unknown as ChildProcess
const { deps, calls } = makeDeps({ isWsl: true, spawn: () => proc })
deps.openExternal = async url => {
calls.opened.push(url)
throw new Error('xdg-open missing')
}
const result = await openExternalUrl('https://example.com', deps)
assert.deepEqual(result, { ok: true })
// openExternalUrl's fire-and-forget WSL path resolves before the spawn
// error can arrive; drive the error to exercise the fallback.
;(proc as unknown as EventEmitter).emit('error', new Error('ENOENT'))
await new Promise(resolve => setTimeout(resolve, 20))
assert.deepEqual(calls.opened, ['https://example.com/'])
assert.deepEqual(calls.notified, [['https://example.com/', 'xdg-open missing']])
})
+126
View File
@@ -0,0 +1,126 @@
/**
* external-open.ts
*
* The single route every external URL open in the desktop app flows through.
* Electron-free: every side effect (shell.openExternal, spawn, the file
* opener, the failure channel) is injected, so the "open failed → notify"
* behavior is unit-tested without loading electron — the same shape as
* native-oauth-login.ts and connection-config.ts. main.ts wires the real deps.
*
* A URL is opened exactly once. An open failure is logged and reported
* through notifyFailure (the renderer shows a fallback modal with the URL);
* the caller reads the result to decide whether an open-failure must also
* abort its own flow (the native-OAuth path fails fast, link paths don't).
*/
import type { ChildProcess, SpawnOptions } from 'node:child_process'
export type ExternalOpenResult =
| { ok: true }
| { ok: false; reason: 'invalid' }
| { ok: false; reason: 'failed'; message: string }
export interface ExternalOpenDeps {
isWsl: boolean
spawn: (cmd: string, args: readonly string[], opts: SpawnOptions) => ChildProcess
openExternal: (url: string) => Promise<void>
openFile: (rawUrl: string) => Promise<void>
notifyFailure: (url: string, message: string) => void
log: (line: string) => void
}
const SUPPORTED_WEB = ['http:', 'https:', 'mailto:']
export function externalOpenErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Open a URL in the system browser. Resolves `invalid` for a URL the route
* does not open (empty, malformed, unsupported scheme). NEVER rejects: an open
* failure is logged + reported through notifyFailure and resolves `failed`,
* so fire-and-forget callers can `void` the result safely.
*/
export async function openExternalUrl(
rawUrl: string,
deps: ExternalOpenDeps
): Promise<ExternalOpenResult> {
const raw = String(rawUrl || '').trim()
if (!raw) {
return { ok: false, reason: 'invalid' }
}
let parsed: URL
try {
parsed = new URL(raw)
} catch {
return { ok: false, reason: 'invalid' }
}
if (parsed.protocol === 'file:') {
try {
await deps.openFile(raw)
} catch {
// main's openFile handles its own fallback; never surfaced here
}
return { ok: true }
}
if (!SUPPORTED_WEB.includes(parsed.protocol)) {
return { ok: false, reason: 'invalid' }
}
const url = parsed.toString()
if (deps.isWsl) {
return openViaWsl(url, deps)
}
try {
await deps.openExternal(url)
return { ok: true }
} catch (error) {
return failOpen(deps, url, error)
}
}
async function openViaWsl(url: string, deps: ExternalOpenDeps): Promise<ExternalOpenResult> {
deps.log(`[link] opening via WSL→Windows: ${url}`)
const proc = deps.spawn('cmd.exe', ['/c', 'start', '""', url], {
detached: true,
stdio: 'ignore',
windowsHide: true
})
// 'error' only fires when the process could not be spawned. In that case
// fall back to xdg-open; if that also fails, surface it. The handler runs
// asynchronously after this function has already resolved.
proc.on('error', error => {
deps.log(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`)
deps.openExternal(url).catch(openError => {
failOpen(deps, url, openError)
})
})
try {
proc.unref()
} catch {
// unref can throw on an already-closed handle in some node versions
}
return { ok: true }
}
function failOpen(deps: ExternalOpenDeps, url: string, error: unknown): ExternalOpenResult {
const message = externalOpenErrorMessage(error)
deps.log(`[link] openExternal failed: ${message}`)
deps.notifyFailure(url, message)
return { ok: false, reason: 'failed', message }
}
@@ -131,3 +131,44 @@ test('remote apply without a waiter has no first-run side effects', async () =>
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
const bundledDamagedBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bundled-unusable',
platform: 'win32',
local: 'bundled-damaged'
} as const
test('the gate prompts on bundled-unusable (a damaged bundled payload)', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bundledDamagedBackend)
assert.equal(await settledState(pending), 'pending')
assert.equal(prompts.length, 1)
assert.equal(gate.hasWaiter(), true)
})
test('continueLocal settles bundled-unusable without any install side effect', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bundledDamagedBackend)
gate.continueLocal()
assert.equal(await pending, 'continue-local')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('remote apply on bundled-unusable settles for a remote re-resolution', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bundledDamagedBackend)
const resumedWaiter = gate.abandonForRemoteApply()
assert.equal(resumedWaiter, true)
assert.equal(hidden, 1)
assert.equal(await pending, 'remote-applied')
})
+10 -1
View File
@@ -2,6 +2,8 @@ interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
platform?: string
/** What the local setup card represents: 'none' = installer offer, the rest = use existing. */
local?: 'none' | 'installed' | 'bundled' | 'bundled-damaged'
}
interface FirstRunSetupGateOptions {
@@ -57,8 +59,15 @@ export function createFirstRunSetupGate({
}
}
// Both sentinels need a user decision: 'bootstrap-needed' offers install-or-
// connect; 'bundled-unusable' (a bundled install whose payload is damaged)
// offers connect-or-reinstall — never install.
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
Boolean(
backend &&
(backend.kind === 'bootstrap-needed' || backend.kind === 'bundled-unusable') &&
!localBootstrapConfirmed
)
const wait = async (backend?: FirstRunSetupBackend | null) => {
if (!shouldGate(backend)) {
@@ -117,3 +117,108 @@ test('a primary apply without an active first-run gate tears down before reconne
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
})
const bundledDamagedBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bundled-unusable',
platform: 'win32',
local: 'bundled-damaged'
}
test('a bundled-unusable continueLocal never reaches an installer — the local step refuses instead', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const runBootstrap = vi.fn()
// Production ordering (electron/main.ts ensureRuntime): the
// bundled-unusable branch throws the reinstall error BEFORE any
// runBootstrap call. Model that contract — the installer must never fire
// for a bundled install, damaged or not.
const ensureLocalRuntime = vi.fn(async (backend: any) => {
assert.equal(backend.kind, 'bundled-unusable')
assert.equal(backend.local, 'bundled-damaged')
throw new Error('This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.')
})
const pendingConnection = runPrimaryBackendStartup({
connectRemote: vi.fn(),
ensureLocalRuntime,
prepareLocalBackend: vi.fn(async () => bundledDamagedBackend),
resolveRemote: vi.fn(async () => null),
waitForDecision: gate.wait,
waitForLocalStart: vi.fn(async () => {})
})
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.continueLocal()
await assert.rejects(pendingConnection, /Reinstall Hermes Desktop to restore it/)
assert.equal(ensureLocalRuntime.mock.calls.length, 1)
assert.equal(runBootstrap.mock.calls.length, 0)
})
test('a bundled-unusable remote apply connects without ensuring or bootstrapping locally', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const candidateRemote = {
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
}
let savedRemote: typeof candidateRemote | null = null
const resolveRemote = vi.fn(async () => savedRemote)
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
const ensureLocalRuntime = vi.fn()
const teardownPrimaryBackend = vi.fn(async () => {})
const cancelSshBootstrap = vi.fn(async () => {})
const teardownSsh = vi.fn(async () => {})
const clearLocalBootstrapFailure = vi.fn()
const notifyConnectionApplied = vi.fn()
const pendingConnection = runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend: vi.fn(async () => bundledDamagedBackend),
resolveRemote,
waitForDecision: gate.wait,
waitForLocalStart: vi.fn(async () => {})
})
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
// Remote is the escape hatch on a damaged bundle: persist + re-home, same
// production ordering as the bootstrap-needed path.
savedRemote = candidateRemote
await applyConnectionChange({
cancelAndWait: cancelSshBootstrap,
isPrimary: true,
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: gate.abandonForRemoteApply,
teardownPrimaryBackend
}),
scope: '',
sendApplied: notifyConnectionApplied,
stopPool: vi.fn(),
teardownPrimary: teardownPrimaryBackend,
teardownSsh
})
assert.deepEqual(await pendingConnection, {
kind: 'remote',
connection: { ...candidateRemote, mode: 'remote' }
})
assert.deepEqual(resolveRemote.mock.calls, [[], []])
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
})
@@ -0,0 +1,57 @@
// installShape() — the ONE split every desktop lifecycle decision gates on.
//
// The stamp is a constant of the artifact; the shape is derived from it
// and never from filesystem probes. These tests pin the derivation table
// and the probe-independence (a stamp object is enough — no fs access).
import { describe, expect, test } from 'vitest'
import { installShape, type InstallStamp } from './install-stamp'
function stamp(overrides: Partial<InstallStamp>): InstallStamp {
return {
schemaVersion: 2,
commit: 'a'.repeat(40),
commitDate: 1755000000,
branch: 'main',
builtAt: '2026-08-14T00:00:00Z',
dirty: false,
source: 'ci',
distribution: 'desktop-app',
updateMechanism: 'electron-updater',
baseVersion: '0.21.0',
displayVersion: '0.21.0',
distance: 0,
payload: 'bootstrap',
tag: null,
...overrides
}
}
describe('installShape', () => {
test('a bundled stamp is the bundled shape', () => {
expect(installShape(stamp({ payload: 'bundled', tag: 'v0.21.0' }))).toBe('bundled')
})
test('a bootstrap stamp is a checkout — its runtime is a local install', () => {
expect(installShape(stamp({ payload: 'bootstrap' }))).toBe('checkout')
})
test('a light stamp is not bundled (no runtime to run at all)', () => {
// Light artifacts never resolve a local backend; what matters here is
// that they can never be told to run venv machinery as "bundled".
expect(installShape(stamp({ payload: 'light' }))).toBe('checkout')
})
test('a dev run (null stamp) is a checkout', () => {
expect(installShape(null)).toBe('checkout')
})
test('the shape comes from the stamp alone — no probe can change it', () => {
// Same stamp, any filesystem state: the answer is a pure function of
// the constant. (The probes live INSIDE the chosen shape as integrity
// checks; see resolveHermesBackend.)
const bundled = stamp({ payload: 'bundled' })
expect(installShape(bundled)).toBe(installShape(bundled))
})
})
+70
View File
@@ -0,0 +1,70 @@
// install-stamp.ts — the typed build-time install stamp.
//
// scripts/write_install_stamp.py writes build/install-stamp.json during
// `npm run build`.
// bundle-electron-main.mjs bakes that file into the
// production bundle by defining the __HERMES_INSTALL_STAMP__ global as
// the stamp. The stamp is a constant of the artifact.
// It cannot be missing, stale, or edited after signing.
//
// Dev bundles and test runs define nothing; the typeof guard makes the
// stamp null there (a dev run has no artifact to be truthful about).
/**
* The desktop artifact kind — which runtime story this artifact tells:
* - 'bootstrap': no runtime in the artifact; first launch bootstraps a
* local install (the default; also what non-desktop stamps carry).
* - 'bundled': the agent runtime ships inside the artifact resources.
* - 'light': no runtime at all; remote connections only.
* Selected at build time by HERMES_DESKTOP_VARIANT (unset = bootstrap).
*/
export type ArtifactKind = 'bootstrap' | 'bundled' | 'light'
/** Mirrors the dict scripts/write_install_stamp.py::build_stamp returns. */
export interface InstallStamp {
schemaVersion: number
commit: string | null
commitDate: number | null
branch: string | null
builtAt: string | null
dirty: boolean
/** Build provenance: where the stamp's facts came from. */
source: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown' | null
/** The steward of a sealed tree ('desktop-app' | 'docker' | 'nix'), when packaged. */
distribution: string | null
/** Who applies the next update. Required in every stamp. */
updateMechanism: 'self' | 'electron-updater' | 'external'
baseVersion: string | null
displayVersion: string | null
distance: number | null
payload: ArtifactKind
/** The pinned release tag. Always set for 'bundled' and 'light', never for 'bootstrap'. */
tag: string | null
}
declare const __HERMES_INSTALL_STAMP__: InstallStamp | undefined
/** The baked stamp of this artifact, or null on dev bundles. */
export const INSTALL_STAMP: Readonly<InstallStamp> | null =
typeof __HERMES_INSTALL_STAMP__ === 'undefined' ? null : Object.freeze(__HERMES_INSTALL_STAMP__)
/**
* The install shape this process runs as — THE single split every
* lifecycle decision gates on (mirror of Python's runtime_tree()):
* - 'bundled': the runtime ships inside the artifact. Venv machinery,
* installers, repair-reinstall escalation and update checkouts must
* never run; drift means rebuild, updates mean the steward.
* - 'checkout': a git tree with venv machinery, provisioner-on-demand
* and `hermes update`.
*
* Derived from the stamp CONSTANT, never from filesystem probes: a
* payload/venv/marker probe answers "is this artifact intact?", not
* "which shape am I?" — those probes remain only as integrity checks
* inside an already-chosen shape (a bundled stamp with a damaged
* payload throws; it must not quietly become a checkout). Dev runs
* (null stamp) and bootstrap artifacts are 'checkout': their runtime
* is a local install the app bootstraps and maintains.
*/
export function installShape(stamp: Readonly<InstallStamp> | null = INSTALL_STAMP): 'bundled' | 'checkout' {
return stamp?.payload === 'bundled' ? 'bundled' : 'checkout'
}
+71 -80
View File
@@ -269,7 +269,11 @@ import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity'
import { adoptPayloadVenv, installIdForRoot, isBundledInstall, resolvePayload, type PayloadInfo } from './payload-backend'
import { checkAppInstallerUpdate, PLACEHOLDER_FEED_BASE_URL, triggerAppInstallerUpdate } from './app-updater'
import { PLACEHOLDER_FEED_BASE_URL } from './app-updater'
import { AppInstallerStrategy } from './updater/app-installer'
import { ExternalStrategy } from './updater/external'
import { resolveUpdaterMechanism } from './updater'
import { writePendingRelaunch, consumePendingRelaunch } from './updater/relaunch'
import { registerPetOverlayIpc } from './pet-overlay-ipc'
import {
buildRegistryProfileRoutes,
@@ -2856,49 +2860,21 @@ async function resolveHealedBranch(updateRoot, branch) {
}
async function checkUpdates() {
// A bundled install has no checkout to pull — the OS App Installer owns
// the update loop for out-of-store MSIX installs. Ask the OS whether a
// newer package is available on the registered .appinstaller source.
// A bundled install has no checkout to pull — resolve the updater
// mechanism once and delegate. Out-of-store MSIX asks the OS whether a
// newer package is on the registered .appinstaller source; Store installs
// report unsupported (the steward owns their update loop).
const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })
if (bundledPayload) {
if (IS_WINDOWS && !isWindowsStore()) {
try {
const check = await checkAppInstallerUpdate({
python: bundledPayload.storePython,
// The checker ships inside the payload's repo snapshot (git archive
// of the committed tree): <payload>/<repo>/apps/desktop/scripts/.
script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'),
run: runPayloadPython
})
const strategy = resolveBundledUpdateStrategy(bundledPayload)
return {
supported: true,
mechanism: 'app-installer',
channel: resolveUpdaterChannelFromStamp(),
currentVersion: app.getVersion(),
latestVersion: null,
updateAvailable: check.available === true,
// null = unknown (checker unavailable) — surface honestly.
error: check.available === null ? (check.error || 'update check unavailable') : undefined,
fetchedAt: Date.now()
}
} catch (error) {
return {
supported: true,
mechanism: 'app-installer',
error: 'check-failed',
message: error?.message || String(error),
fetchedAt: Date.now()
}
}
}
return {
supported: false,
reason: 'bundled-not-appinstaller',
message: 'bundled install: updates are applied by the installer (Microsoft Store or App Installer).',
return strategy.check().catch(error => ({
supported: true,
mechanism: strategy.mechanism,
error: 'check-failed',
message: error?.message || String(error),
fetchedAt: Date.now()
}
}))
}
const updateRoot = resolveUpdateRoot()
@@ -3150,6 +3126,42 @@ let updateInFlight = false
// ── bundled / App Installer helpers ─────────────────────────────────────────
/**
* Resolve the updater strategy for a BUNDLED install (payload present).
* Dispatch mirrors the pre-strategy ladder exactly: win32 out-of-store →
* App Installer arm; Store / other → external (unsupported). The checkout
* ladder below appliesUpdates' bundled branch stays in main.ts (it delegates
* through the same mechanism resolution via the wire's `mechanism` field).
*/
function resolveBundledUpdateStrategy(bundledPayload) {
const mechanism = resolveUpdaterMechanism({
isBundled: true,
isWindows: IS_WINDOWS,
isWindowsStore: isWindowsStore()
})
if (mechanism === 'app-installer') {
return new AppInstallerStrategy({
python: bundledPayload.storePython,
// The checker ships inside the payload's repo snapshot (git archive of
// the committed tree): <payload>/<repo>/apps/desktop/scripts/.
script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'),
run: runPayloadPython,
channel: resolveUpdaterChannelFromStamp(),
light: isLightVariant(),
feedBaseUrl: resolveDesktopFeedBaseUrl(),
shell: { openExternal: url => shell.openExternal(url) },
teardownBundledBackend,
emitUpdateProgress,
appVersion: app.getVersion(),
quit: () => app.quit(),
registerPendingRelaunch: fromVersion => writePendingRelaunch(HERMES_HOME, fromVersion)
})
}
return new ExternalStrategy()
}
/** True when this process is a Microsoft Store deployment. */
function isWindowsStore(): boolean {
return Boolean((process as any).windowsStore)
@@ -3753,47 +3765,15 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
// A bundled install ships its whole runtime as a sealed payload — there
// is no checkout to pull or venv to sync. New app release IS the update.
if (resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })) {
// Out-of-store MSIX installs are App Installer owned: the OS registered
// the .appinstaller URI as the update source at install, so "apply"
// means run the graceful teardown (backend children holding install-dir
// locks must die first — the same teardown the old in-app updater ran
// between download and install), then hand the process to the OS App
// Installer and quit.
if (IS_WINDOWS && !isWindowsStore()) {
const feedBaseUrl = resolveDesktopFeedBaseUrl()
// Delegate to the bundled strategy: out-of-store MSIX runs the graceful
// teardown, hands the swap to the OS App Installer, registers the
// one-shot relaunch marker, and quits; anything else gets the manual
// card (reinstall the app release).
const strategy = resolveBundledUpdateStrategy(
resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })
)
if (feedBaseUrl) {
emitUpdateProgress({
stage: 'restart',
message: 'Applying the Hermes update — the window will close and the App Installer will finish.',
percent: 100
})
await triggerAppInstallerUpdate(
feedBaseUrl,
resolveUpdaterChannelFromStamp(),
isLightVariant(),
{ openExternal: url => shell.openExternal(url) },
teardownBundledBackend
)
// The OS App Installer now owns the swap. Quit so no process holds
// files in the install dir while Windows replaces the package.
app.quit()
return { ok: true, manual: false, bundled: true }
}
}
// No App Installer source (a non-MSIX bundled install, or no feed
// configured): the manual card remains the fallback.
emitUpdateProgress({
stage: 'manual',
message: 'bundled install: update by installing the new app release',
percent: null
})
return { ok: true, manual: true, bundled: true }
return strategy.apply(opts)
}
updateInFlight = true
@@ -17782,6 +17762,17 @@ app.on('open-url', (event, url) => {
})
app.whenReady().then(() => {
// Post-update relaunch detection (App Installer arm): when the previous
// version wrote the one-shot pending-relaunch marker before quitting into
// an OS package swap, consume it here — the renderer toasts "Hermes
// updated to vX.Y.Z" once its bridge is up. Same-version markers (update
// never landed) are deleted silently.
const relaunchInfo = consumePendingRelaunch(HERMES_HOME, app.getVersion())
if (relaunchInfo.wasUpdateRelaunch) {
rememberLog(`[updates] post-update relaunch detected (from ${relaunchInfo.fromVersion})`)
}
// Warm the login-shell PATH resolution immediately so it usually completes
// before the backend start path awaits the same single-flight promise.
void ensureLoginShellPath()
+7
View File
@@ -269,6 +269,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
onExternalOpenFailed: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:external-open-failed', listener)
return () => ipcRenderer.removeListener('hermes:external-open-failed', listener)
},
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
reachPreviewUrl: url => ipcRenderer.invoke('hermes:preview:reach', url),
setActiveConnectionRoute: route => ipcRenderer.send('hermes:connection:active-route', route),
@@ -301,6 +307,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// Fire-and-forget: persists a renderer error-boundary catch (with component
// stack) to desktop.log so crashes survive the window (#79428).
reportRendererError: report => ipcRenderer.send('hermes:logs:renderer-error', report),
logLine: line => ipcRenderer.send('hermes:logs:renderer-line', line),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
@@ -0,0 +1,107 @@
// product-identity.cjs is the single derivation of the desktop product
// identity; electron/product-identity.ts re-exports it. These tests hold
// the identity contract: the TS accessor resolves to the .cjs object, and
// the two variants disagree on every OS-visible marker (side-by-side
// installs must not collide).
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { afterEach, beforeEach, test, vi } from 'vitest'
const require = createRequire(import.meta.url)
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
delete process.env.HERMES_DESKTOP_VARIANT
delete process.env.HERMES_PAYLOAD_TAG
vi.resetModules()
})
async function identityForVariant(variant: string | undefined) {
if (variant === undefined) {
delete process.env.HERMES_DESKTOP_VARIANT
} else {
process.env.HERMES_DESKTOP_VARIANT = variant
}
delete require.cache[require.resolve('../product-identity.cjs')]
vi.resetModules()
return (await import('./product-identity')).PRODUCT_IDENTITY
}
test('light identity is fully distinct from the full identity', async () => {
const full = await identityForVariant(undefined)
const light = await identityForVariant('light')
assert.equal(light.light, true)
// Enumerate the OS-visible identity markers explicitly rather than looping
// Object.keys: `store` is a build-mode flag, legitimately equal (false)
// across variants, so a keys-loop would fail for the wrong reason. Only the
// markers Windows/electron keys on must differ for side-by-side installs.
for (const prop of ['displayName', 'appId', 'appNamePascal', 'msixAppIdWithOrg', 'channel'] as const) {
assert.notEqual(light[prop], full[prop], `${prop} must differ between light and full`)
}
})
test('a nightly payload tag moves BOTH variants onto their nightly feed channel', async () => {
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0-nightly.20260818'
const full = await identityForVariant(undefined)
assert.equal(full.channel, 'nightly')
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0-nightly.20260818'
const light = await identityForVariant('light')
assert.equal(light.channel, 'light-nightly')
})
test('stable tags and tagless dev builds publish to the stable channels', async () => {
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0'
assert.equal((await identityForVariant(undefined)).channel, 'latest')
delete process.env.HERMES_PAYLOAD_TAG
assert.equal((await identityForVariant('light')).channel, 'light')
})
test('bundled variant keeps the Hermes display name and a distinct appId', async () => {
const full = await identityForVariant(undefined)
const bundled = await identityForVariant('bundled')
assert.equal(bundled.light, false)
assert.equal(bundled.displayName, full.displayName)
assert.equal(bundled.appNamePascal, 'HermesBundled')
assert.notEqual(bundled.appId, full.appId)
assert.equal(bundled.channel, 'latest')
})
test('store inherits the bundled app identity (shared userData) but swaps the MSIX packaging identity', async () => {
const bundled = await identityForVariant('bundled')
const store = await identityForVariant('store')
// Same Electron app: displayName/appNamePascal (-> shared userData dir),
// appId, and the out-of-store org-prefixed name are all inherited.
assert.equal(store.store, true)
assert.equal(store.light, false)
assert.equal(store.displayName, bundled.displayName)
assert.equal(store.appNamePascal, bundled.appNamePascal)
assert.equal(store.appId, bundled.appId)
assert.equal(store.msixAppIdWithOrg, bundled.msixAppIdWithOrg)
// The store build never publishes to a feed.
assert.equal(store.channel, null)
})
test('store carries the Partner Center MSIX identity and no other variant does', async () => {
const store = await identityForVariant('store')
assert.deepEqual(store.storeMsix, {
identityName: 'NousResearchInc.HermesAgent',
publisher: 'CN=EE6D86E4-606F-4E38-B940-AD7248C9D519',
publisherDisplayName: 'Nous Research Inc.'
})
for (const v of [undefined, 'bundled', 'light'] as const) {
assert.equal((await identityForVariant(v)).storeMsix, undefined, `variant ${v} must carry no storeMsix`)
}
})
+19
View File
@@ -0,0 +1,19 @@
// product-identity.ts — the typed build-time product identity.
//
// product-identity.cjs is the single derivation of every name-shaped
// value a variant owns (product name, appId, channel). Dev bundles and
// test runs import the .cjs itself so the derivation exists in exactly
// one place.
import devIdentity from '../product-identity.cjs'
/** Mirrors product-identity.cjs (see product-identity.d.cts). */
export type ProductIdentity = typeof devIdentity
declare const __HERMES_PRODUCT_IDENTITY__: ProductIdentity
/** The baked identity of this artifact (dev bundles derive it live). */
export const PRODUCT_IDENTITY: Readonly<ProductIdentity> =
typeof __HERMES_PRODUCT_IDENTITY__ === 'undefined'
? Object.freeze(devIdentity)
: Object.freeze(__HERMES_PRODUCT_IDENTITY__)
@@ -0,0 +1,70 @@
// updater/app-installer-strategy.test.ts — the apply-flow contract: the
// one-shot relaunch marker is written BEFORE the OS hand-off, teardown runs
// before the trigger, and quit is unconditional (no relaunch question).
import { describe, expect, it } from 'vitest'
import { AppInstallerStrategy } from './app-installer'
import type { AppInstallerStrategyDeps } from './app-installer'
function makeDeps(over: Partial<AppInstallerStrategyDeps> = {}) {
const calls: string[] = []
const deps: AppInstallerStrategyDeps = {
python: 'python.exe',
script: 'check.py',
run: async () => ({ code: 0, stdout: '{"available": true}' }),
channel: 'stable',
light: false,
feedBaseUrl: 'https://updates.example/hermes-desktop',
shell: { openExternal: async () => { calls.push('openExternal') } },
teardownBundledBackend: async () => { calls.push('teardown') },
emitUpdateProgress: () => {},
appVersion: '0.18.2',
quit: () => { calls.push('quit') },
registerPendingRelaunch: () => { calls.push('relaunch-marker'); return true },
...over
}
return { deps, calls }
}
describe('AppInstallerStrategy.apply', () => {
it('writes the relaunch marker before teardown, trigger, and quit — order is the contract', async () => {
const { deps, calls } = makeDeps()
const strategy = new AppInstallerStrategy(deps)
const result = await strategy.apply({})
expect(result).toEqual({ ok: true, manual: false, bundled: true, mechanism: 'app-installer' })
expect(calls).toEqual(['relaunch-marker', 'teardown', 'openExternal', 'quit'])
})
it('fails open: a marker-write failure never blocks the update', async () => {
const { deps, calls } = makeDeps({ registerPendingRelaunch: () => false })
const result = await new AppInstallerStrategy(deps).apply({})
expect(result.ok).toBe(true)
expect(calls).toContain('quit')
})
it('no feed URL → manual card, no teardown, no quit', async () => {
const { deps, calls } = makeDeps({ feedBaseUrl: '' })
const result = await new AppInstallerStrategy(deps).apply({})
expect(result).toEqual({ ok: true, manual: true, bundled: true, mechanism: 'app-installer' })
expect(calls).toEqual([])
})
})
describe('AppInstallerStrategy.check', () => {
it('threads the OS checker result onto the mechanism wire', async () => {
const { deps } = makeDeps({ run: async () => ({ code: 0, stdout: '{"available": true}' }) })
const status = await new AppInstallerStrategy(deps).check()
expect(status.mechanism).toBe('app-installer')
expect(status.updateAvailable).toBe(true)
expect(status.error).toBeUndefined()
})
it('unknown availability is an error on the wire, never "no update"', async () => {
const { deps } = makeDeps({ run: async () => ({ code: 1, stdout: '{"available": null, "error": "winrt missing"}' }) })
const status = await new AppInstallerStrategy(deps).check()
expect(status.updateAvailable).toBe(false)
expect(status.error).toBe('winrt missing')
})
})
@@ -0,0 +1,149 @@
// updater/app-installer.ts — the win32 out-of-store MSIX strategy.
//
// The OS App Installer owns the apply. The package was installed from an
// .appinstaller, which registered the feed URI as the package's update source;
// the OS checks it and swaps the package wholesale. The app's only jobs:
// check() ask the OS whether an update is available (via the bundled
// payload python's winrt), surfacing UNKNOWN honestly;
// apply() run graceful teardown, trigger ms-appinstaller:, quit — and
// a pending-relaunch marker so Hermes comes back by itself.
//
// Pure-injectable: the impure pieces (python runner, shell, quit, relaunch
// marker) are injected, so vitest covers the arm without a payload.
import {
checkAppInstallerUpdate,
type AppInstallerCheck,
type PayloadPythonRunner,
triggerAppInstallerUpdate,
win32AppInstallerFeedPath
} from '../app-updater'
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
export interface AppInstallerStrategyDeps {
/** Absolute path to the bundled payload python (tools/<entry>/python.exe). */
python: string
/** The checker script's absolute path (payload repo snapshot). */
script: string
run: PayloadPythonRunner['run']
/** Channel + variant from the baked install stamp. */
channel: 'stable' | 'nightly'
light: boolean
/** The App Installer feed base URL; empty when nothing configured it. */
feedBaseUrl: string
shell: { openExternal: (url: string) => Promise<void> }
/** Graceful backend teardown before the package swap. */
teardownBundledBackend: () => void | Promise<void>
/** Progress emitter for the updates overlay. */
emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void
/** App version label for the status wire. */
appVersion: string
/** Quit the app (after handing the swap to the OS). */
quit: () => void
/**
* Register a one-shot post-update relaunch (run-key entry or equivalent)
* before quitting, so Hermes reopens on the new version with no user
* action. The new version's first run detects + deletes the marker.
* Returns false when registration failed (relaunch stays manual).
*/
registerPendingRelaunch: (targetVersion: string) => boolean
}
export interface CheckOutcome {
status: UpdaterStatusWire
}
/**
* Ask the OS whether an App Installer update is available. `available: null`
* (checker unavailable) is surfaced as an honest unknown on the wire —
* NEVER as "no update".
*/
export function appInstallerCheckToStatus(check: AppInstallerCheck, appVersion: string): UpdaterStatusWire {
return {
supported: true,
mechanism: 'app-installer',
currentVersion: appVersion,
updateAvailable: check.available === true,
// null = unknown (checker unavailable) — surface honestly, never "no update".
error: check.available === null ? check.error || 'update check unavailable' : undefined,
fetchedAt: Date.now()
}
}
export class AppInstallerStrategy {
readonly mechanism = 'app-installer' as const
constructor(private readonly deps: AppInstallerStrategyDeps) {}
async check(): Promise<UpdaterStatusWire> {
const { code, stdout } = await this.deps.run(this.deps.python, this.deps.script)
const check = parseCheckOutput(code, stdout)
return appInstallerCheckToStatus(check, this.deps.appVersion)
}
async apply(_opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire> {
const feedBaseUrl = this.deps.feedBaseUrl
if (!feedBaseUrl) {
this.deps.emitUpdateProgress({
stage: 'manual',
message: 'bundled install: update by installing the new app release',
percent: null
})
return { ok: true, manual: true, bundled: true, mechanism: this.mechanism }
}
this.deps.emitUpdateProgress({
stage: 'restart',
message: 'Applying the Hermes update — the window will close and the App Installer will finish.',
percent: 100
})
// Unconditional relaunch: register the one-shot marker BEFORE the swap so
// Hermes comes back on the new version with no user action.
this.deps.registerPendingRelaunch(this.deps.appVersion)
await triggerAppInstallerUpdate(
feedBaseUrl,
this.deps.channel,
this.deps.light,
this.deps.shell,
this.deps.teardownBundledBackend
)
this.deps.quit()
return { ok: true, manual: false, bundled: true, mechanism: this.mechanism }
}
}
/** Parse the payload-python checker's output into an AppInstallerCheck. */
export function parseCheckOutput(code: number, stdout: string): AppInstallerCheck {
const text = stdout.trim()
let parsed: { available?: boolean | null; availability?: string; error?: string; reason?: string } | null = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
if (parsed && typeof parsed.available === 'boolean') {
return { available: parsed.available, availability: parsed.availability, error: parsed.error }
}
if (parsed && parsed.available === null) {
return { available: null, error: parsed.error || 'checker returned unknown' }
}
if (code !== 0) {
return { available: null, error: parsed?.error || `checker exited ${code}` }
}
return { available: null, error: 'checker returned no availability' }
}
export { win32AppInstallerFeedPath }
export type { AppInstallerCheck, PayloadPythonRunner }
+51
View File
@@ -0,0 +1,51 @@
// updater/checkout.ts — the git-checkout strategies (win + posix + manual).
//
// These wrap the existing bodies in main.ts rather than re-implementing
// them: the checkout update flow is deeply entangled with the god-file's
// closure helpers (resolveHealedBranch, venv-lock release, blocker scans,
// hand-off marker claims). The strategy adapter delegates; the ladder's
// TAIL (manual `hermes update` command card) lives here because it is pure
// enough to test.
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
export interface CheckoutStrategyDeps {
/** The existing checkUpdates git-checkout body (unchanged). */
checkBody: () => Promise<UpdaterStatusWire>
/** The existing applyUpdates git-checkout body (unchanged). */
applyBody: (opts: { stopSafeBlockers?: boolean }) => Promise<UpdaterApplyResultWire>
isWindows: boolean
}
export class CheckoutStrategy {
readonly mechanism: 'windows-handoff' | 'posix-handoff'
constructor(private readonly deps: CheckoutStrategyDeps) {
this.mechanism = deps.isWindows ? 'windows-handoff' : 'posix-handoff'
}
async check(): Promise<UpdaterStatusWire> {
const status = await this.deps.checkBody()
status.mechanism = this.mechanism
return status
}
async apply(opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire> {
const result = await this.deps.applyBody(opts)
result.mechanism = this.mechanism
return result
}
}
/**
* The manual command card for a checkout with no staged updater: the exact
* `hermes update` line to run, branch-pinned to the checkout's current branch
* for non-main (bare `hermes update` would silently switch the install
* off-branch). Extracted from applyUpdates so the wording contract is
* unit-testable.
*/
export function buildManualUpdateCommand(currentBranch: string | null | undefined): string {
return currentBranch && currentBranch !== 'HEAD' && currentBranch !== 'main'
? `hermes update --branch ${currentBranch}`
: 'hermes update'
}
+30
View File
@@ -0,0 +1,30 @@
// updater/external.ts — the steward-owned strategy.
//
// Store deployments (process.windowsStore) and stamps whose updateMechanism
// is 'external': the steward (Microsoft Store / App Installer on-launch
// re-check) owns the update loop. The app does not check, does not apply —
// it only tells the user updates happen outside the app.
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
export const EXTERNAL_UNSUPPORTED_MESSAGE =
'bundled install: updates are applied by the installer (Microsoft Store or App Installer).'
export class ExternalStrategy {
readonly mechanism = 'external' as const
readonly supported = false
async check(): Promise<UpdaterStatusWire> {
return {
supported: false,
mechanism: 'external',
reason: 'bundled-not-appinstaller',
message: EXTERNAL_UNSUPPORTED_MESSAGE,
fetchedAt: Date.now()
}
}
async apply(): Promise<UpdaterApplyResultWire> {
return { ok: true, manual: true, bundled: true, mechanism: 'external' }
}
}
+90
View File
@@ -0,0 +1,90 @@
// updater/index.ts — the desktop update strategy surface.
//
// Every install shape updates through a different owner:
// app-installer out-of-store MSIX on win32 — the OS App Installer owns
// the apply (the .appinstaller feed registered at install).
// external Microsoft Store / steward-owned deployments — no in-app
// updater at all.
// windows-handoff git checkout on win32 — detached updater binary or the
// repo hand-off script owns the swap.
// posix-handoff git checkout on macOS/Linux — the repo posix hand-off
// script owns the swap.
// manual checkout with no staged updater — the user runs
// `hermes update` themselves.
//
// The mechanism is resolved ONCE from runtime facts (payload presence,
// platform, store flag) by resolveUpdaterMechanism — a pure function, unit
// tested — and every strategy reports it on the wire so the renderer can
// tailor copy per mechanism without probing the install shape itself.
export type UpdaterMechanism =
| 'app-installer'
| 'external'
| 'windows-handoff'
| 'posix-handoff'
| 'manual'
/** The facts the mechanism dispatch keys on. Pure data — injectable for tests. */
export interface MechanismFacts {
/** A bundled payload ships inside this artifact (sealed runtime). */
isBundled: boolean
isWindows: boolean
/** This process is a Microsoft Store deployment. */
isWindowsStore: boolean
}
/**
* Resolve which mechanism owns updates for this install. Precedence mirrors
* the historical checkUpdates/applyUpdates ladder in main.ts exactly:
* payload-probe first, store-flag second, platform third. Behavior-preserving.
*/
export function resolveUpdaterMechanism(facts: MechanismFacts): UpdaterMechanism {
if (facts.isBundled) {
return facts.isWindows && !facts.isWindowsStore ? 'app-installer' : 'external'
}
return facts.isWindows ? 'windows-handoff' : 'posix-handoff'
}
/** The status shape main.ts already sends over `hermes:updates:check`. */
export interface UpdaterStatusWire {
supported: boolean
mechanism?: UpdaterMechanism
updateAvailable?: boolean
branch?: string
currentBranch?: string
reason?: string
message?: string
error?: string
behind?: number | null
currentSha?: string
currentVersion?: string
channel?: 'stable' | 'nightly'
latestTag?: string | null
targetSha?: string
commits?: { sha: string; summary: string; author: string; at: number }[]
dirty?: boolean
hermesRoot?: string
fetchedAt?: number
}
/** The result shape main.ts already sends over `hermes:updates:apply`. */
export interface UpdaterApplyResultWire {
ok: boolean
mechanism?: UpdaterMechanism
error?: string
message?: string
manual?: boolean
bundled?: boolean
command?: string
hermesRoot?: string
handedOff?: boolean
updater?: string
[key: string]: unknown
}
export interface UpdaterStrategy {
readonly mechanism: UpdaterMechanism
check(): Promise<UpdaterStatusWire>
apply(opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire>
}
+116
View File
@@ -0,0 +1,116 @@
// updater/relaunch.ts — unconditional relaunch after an App Installer swap.
//
// Contract: clicking Update always ends in Hermes reopening on the new
// version — no user action between quit and relaunch. Implementation: a
// one-shot pending-relaunch marker written before we quit. The OS App
// Installer swaps the package; on next launch (whenever it happens) Hermes
// reads the marker, shows the "updated" toast, and deletes it.
//
// The marker is a JSON file under HERMES_HOME (not a run-key / scheduled
// task): a registry entry can't be made one-shot safely from a dying process,
// and a file survives the package swap (HERMES_HOME lives outside the
// package). For the actual relaunch we rely on Windows restarting the app
// when an MSIX package update completes while the app registered itself
// for restart (RegisterApplicationRestart) — the marker's job is only to
// tell the NEW version it was an update relaunch, so it can toast + clean up.
//
// If the OS does NOT relaunch (update applied later, on next launch), the
// marker is still correct: the first launch after the swap detects it and
// toasts. A stale marker (update cancelled/failed) self-deletes when its
// expected-version no longer matches a later install... no — cancelled
// updates mean the version never changed, so we stamp with "expected NEW
// version unknown" instead: the marker only records that an update was
// STARTED; the new version decides "was it really me?" by comparing versions
// recorded at write time vs launch time.
import * as fs from 'node:fs'
import * as path from 'node:path'
export interface PendingRelaunchMarker {
schemaVersion: 1
/** Version of the app that wrote the marker (pre-update). */
fromVersion: string
/** Wall-clock ms when the update was triggered. */
startedAt: number
}
const MARKER_FILENAME = 'pending-update-relaunch.json'
function markerPath(hermesHome: string): string {
return path.join(hermesHome, MARKER_FILENAME)
}
/** Pure: the filename constant, for tests. */
export const PENDING_RELAUNCH_FILENAME = MARKER_FILENAME
/**
* Register the one-shot post-update relaunch marker. Best-effort — a failure
* to write never blocks the update; relaunch just stays manual.
*/
export function writePendingRelaunch(
hermesHome: string,
fromVersion: string,
writeFile: (file: string, contents: string) => void = (f, c) => fs.writeFileSync(f, c)
): boolean {
try {
const marker: PendingRelaunchMarker = { schemaVersion: 1, fromVersion, startedAt: Date.now() }
writeFile(markerPath(hermesHome), JSON.stringify(marker))
return true
} catch {
return false
}
}
export interface ConsumedRelaunch {
/** True when this launch IS the post-update relaunch (new version). */
wasUpdateRelaunch: boolean
/** The version the update started from, when the marker was present. */
fromVersion?: string
}
export interface RelaunchFsDeps {
existsSync?: (file: string) => boolean
readFileSync?: (file: string) => string
unlinkSync?: (file: string) => void
}
/**
* First-run hook: detect + consume the pending-relaunch marker. Returns
* wasUpdateRelaunch=true only when the CURRENT version differs from the
* marker's fromVersion — the same version means the update never landed
* (cancelled/failed OS install), so the marker is deleted and no toast fires.
*/
export function consumePendingRelaunch(
hermesHome: string,
currentVersion: string,
deps: RelaunchFsDeps = {}
): ConsumedRelaunch {
const existsSync = deps.existsSync ?? ((file: string) => fs.existsSync(file))
const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, 'utf8'))
const unlinkSync = deps.unlinkSync ?? ((file: string) => fs.unlinkSync(file))
const file = markerPath(hermesHome)
if (!existsSync(file)) {
return { wasUpdateRelaunch: false }
}
let fromVersion: string | undefined
try {
const raw = JSON.parse(readFileSync(file)) as PendingRelaunchMarker
fromVersion = typeof raw.fromVersion === 'string' ? raw.fromVersion : undefined
} catch {
fromVersion = undefined
}
// Consume unconditionally — the marker is one-shot.
try {
unlinkSync(file)
} catch {
// Best-effort cleanup; a leftover marker is re-consumed harmlessly.
}
return { wasUpdateRelaunch: fromVersion !== undefined && fromVersion !== currentVersion, fromVersion }
}
@@ -0,0 +1,149 @@
// updater/updater.test.ts — resolution precedence + wire-shape contracts
// for the strategy layer. Pure DI: no Electron, no payload.
import { describe, expect, it } from 'vitest'
import { resolveUpdaterMechanism } from './index'
import { appInstallerCheckToStatus, parseCheckOutput } from './app-installer'
import { buildManualUpdateCommand } from './checkout'
import { writePendingRelaunch, consumePendingRelaunch, PENDING_RELAUNCH_FILENAME } from './relaunch'
describe('resolveUpdaterMechanism — precedence', () => {
it('bundled win32 out-of-store → app-installer', () => {
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: false })).toBe('app-installer')
})
it('bundled win32 Store → external (steward owns updates)', () => {
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: true })).toBe('external')
})
it('bundled posix → external', () => {
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: false, isWindowsStore: false })).toBe('external')
})
it('checkout win32 → windows-handoff', () => {
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: false })).toBe('windows-handoff')
})
it('checkout posix → posix-handoff', () => {
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: false, isWindowsStore: false })).toBe('posix-handoff')
})
it('store flag never downgrades a checkout (probe only fires for bundled)', () => {
// isWindowsStore on a checkout is meaningless; the resolver must not
// route a win32 checkout to external on a stray true.
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: true })).toBe('windows-handoff')
})
})
describe('app-installer check → status wire', () => {
it('available true → updateAvailable, no error', () => {
const s = appInstallerCheckToStatus({ available: true }, '0.18.2')
expect(s.supported).toBe(true)
expect(s.mechanism).toBe('app-installer')
expect(s.updateAvailable).toBe(true)
expect(s.error).toBeUndefined()
})
it('available false → updateAvailable false, NO error (honest no-update)', () => {
const s = appInstallerCheckToStatus({ available: false }, '0.18.2')
expect(s.updateAvailable).toBe(false)
expect(s.error).toBeUndefined()
})
it('available null → updateAvailable false WITH error (honest unknown, never "no update")', () => {
const s = appInstallerCheckToStatus({ available: null, error: 'no winrt' }, '0.18.2')
expect(s.updateAvailable).toBe(false)
expect(s.error).toBe('no winrt')
})
})
describe('parseCheckOutput', () => {
it('parses boolean true/false availability', () => {
expect(parseCheckOutput(0, '{"available": true, "availability": "Available"}')).toEqual({
available: true,
availability: 'Available',
error: undefined
})
expect(parseCheckOutput(0, '{"available": false}')).toEqual({ available: false, availability: undefined, error: undefined })
})
it('available null surfaces the checker error', () => {
expect(parseCheckOutput(2, '{"available": null, "error": "winrt import failed"}')).toEqual({
available: null,
error: 'winrt import failed'
})
})
it('non-zero exit without parseable output names the exit code', () => {
expect(parseCheckOutput(1, 'boom')).toEqual({ available: null, error: 'checker exited 1' })
})
it('exit 0 with no availability is unknown, not no-update', () => {
expect(parseCheckOutput(0, '')).toEqual({ available: null, error: 'checker returned no availability' })
})
})
describe('buildManualUpdateCommand', () => {
it('bare command on main and detached HEAD', () => {
expect(buildManualUpdateCommand('main')).toBe('hermes update')
expect(buildManualUpdateCommand('HEAD')).toBe('hermes update')
expect(buildManualUpdateCommand(null)).toBe('hermes update')
})
it('branch-pinned for non-main checkouts', () => {
expect(buildManualUpdateCommand('ethie/pm')).toBe('hermes update --branch ethie/pm')
})
})
describe('pending relaunch marker', () => {
const KEY = PENDING_RELAUNCH_FILENAME
function fakeFs(files: Record<string, string>) {
const has = (f: string) => f.replace(/\\/g, '/').endsWith(`/${KEY}`) || f === KEY
return {
existsSync: (f: string) => has(f),
readFileSync: (f: string) => files[Object.keys(files).find(k => k.replace(/\\/g, '/') === f.replace(/\\/g, '/')) ?? f],
unlinkSync: (f: string) => {
const key = Object.keys(files).find(k => k.replace(/\\/g, '/') === f.replace(/\\/g, '/'))
if (key) delete files[key]
}
}
}
it('write then consume on a different version = update relaunch', () => {
const files: Record<string, string> = {}
expect(writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })).toBe(true)
expect(JSON.parse(Object.values(files)[0]).fromVersion).toBe('0.18.2')
const r = consumePendingRelaunch('/home', '0.18.3', fakeFs(files))
expect(r.wasUpdateRelaunch).toBe(true)
expect(r.fromVersion).toBe('0.18.2')
// one-shot: consumed
expect(Object.keys(files)).toHaveLength(0)
})
it('same version = update never landed; marker consumed silently', () => {
const files: Record<string, string> = {}
writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })
const r = consumePendingRelaunch('/home', '0.18.2', fakeFs(files))
expect(r.wasUpdateRelaunch).toBe(false)
expect(Object.keys(files)).toHaveLength(0)
})
it('no marker = normal launch', () => {
expect(consumePendingRelaunch('/home', '0.18.3', fakeFs({})).wasUpdateRelaunch).toBe(false)
})
it('corrupt marker is consumed and treated as unknown', () => {
const files: Record<string, string> = {}
writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })
// corrupt the contents under the same key
for (const k of Object.keys(files)) files[k] = 'not json'
const r = consumePendingRelaunch('/home', '0.18.3', fakeFs(files))
expect(r.wasUpdateRelaunch).toBe(false)
expect(Object.keys(files)).toHaveLength(0)
})
})
+7 -125
View File
@@ -34,12 +34,13 @@
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
"pack": "npm run build && npm run builder -- --dir --publish never",
"dist": "npm run build && npm run builder",
"payload": "node scripts/stage-payload.mjs",
"dist:bundled": "npm run payload && npm run dist",
"dist:mac": "npm run build && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run builder -- --win",
"dist:win:msi": "npm run build && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
"dist:win": "npm run build && npm run builder -- --win msix",
"dist:win:msix": "npm run build && npm run builder -- --win msix",
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
"perf": "node scripts/perf/run.mjs",
"update:shim": "bash ../../scripts/desktop-update/repro.sh shim",
@@ -53,10 +54,10 @@
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:msix": "node scripts/test-desktop.mjs msix",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit && tsc --ignoreConfig --allowJs --checkJs --noEmit --skipLibCheck --module nodenext --moduleResolution nodenext --target es2022 electron-builder.config.cjs",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
@@ -173,7 +174,7 @@
"concurrently": "10.0.4",
"cross-env": "10.1.0",
"electron": "40.10.2",
"electron-builder": "26.15.3",
"electron-builder": "27.0.0-alpha.6",
"esbuild": "0.28.1",
"jsdom": "29.1.1",
"prettier": "3.9.5",
@@ -183,124 +184,5 @@
"vite": "8.2.0",
"vitest": "4.1.10",
"wait-on": "9.0.10"
},
"build": {
"electronVersion": "40.10.2",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"protocols": [
{
"name": "Hermes Protocol",
"schemes": [
"hermes"
]
}
],
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {
"output": "release"
},
"files": [
"dist/**",
"assets/**",
"public/**",
"package.json"
],
"beforeBuild": "scripts/before-build.mjs",
"beforePack": "scripts/before-pack.mjs",
"afterPack": "scripts/after-pack.mjs",
"extraResources": [
{
"from": "build/install-stamp.json",
"to": "install-stamp.json"
},
{
"from": "assets/icon.ico",
"to": "icon.ico"
}
],
"asar": true,
"afterSign": "scripts/notarize.mjs",
"asarUnpack": [
"**/*.node",
"**/prebuilds/**",
"dist/**"
],
"mac": {
"category": "public.app-category.developer-tools",
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
"extendInfo": {
"CFBundleDisplayName": "Hermes",
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations.",
"NSCalendarsUsageDescription": "Hermes needs access to Calendar to provide requested meeting and scheduling support.",
"NSCalendarsFullAccessUsageDescription": "Hermes needs full access to Calendar to read and manage events when explicitly requested.",
"NSRemindersUsageDescription": "Hermes needs access to Reminders to provide requested personal-assistant and scheduling support.",
"NSRemindersFullAccessUsageDescription": "Hermes needs full access to Reminders to read and manage reminders when explicitly requested.",
"NSScreenCaptureUsageDescription": "Hermes captures the screen when you ask the agent to screenshot or record it.",
"NSLocalNetworkUsageDescription": "Hermes connects to devices on your local network when a plugin or feature you enable requests it.",
"NSAppleMusicUsageDescription": "Hermes accesses your music library when a plugin or feature you enable requests it."
},
"gatekeeperAssess": false,
"hardenedRuntime": true,
"target": [
"dmg",
"zip"
]
},
"dmg": {
"title": "Install Hermes",
"backgroundColor": "#f5f5f7",
"iconSize": 96,
"window": {
"width": 560,
"height": 360
},
"contents": [
{
"x": 160,
"y": 170,
"type": "file"
},
{
"x": 400,
"y": 170,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"legalTrademarks": "Hermes",
"target": [
"nsis",
"msi"
],
"signAndEditExecutable": false
},
"linux": {
"category": "Development",
"maintainer": "Nous Research <support@nousresearch.com>",
"synopsis": "Native desktop shell for Hermes Agent.",
"target": [
"AppImage",
"deb",
"rpm"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"shortcutName": "Hermes",
"uninstallDisplayName": "Hermes",
"warningsAsErrors": false
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// The desktop product identity — THE single source for every name-shaped
// value a variant owns. HERMES_DESKTOP_VARIANT=light builds "Hermes
// Light", the remote-only client; everything else is full "Hermes".
//
// Consumed at build time by electron-builder.config.cjs (packaging
// identity). electron/product-identity.ts is the typed runtime accessor.
// @ts-check
/// <reference types="node" />
'use strict'
const variants = {
'': { display: 'Hermes', kebab: 'hermes', pascal: 'Hermes' },
light: {
display: 'Hermes Light',
kebab: 'hermes-light',
pascal: 'HermesLight'
},
bundled: {
display: 'Hermes',
kebab: 'hermes-bundled',
pascal: 'HermesBundled'
}
}
const variant = process.env.HERMES_DESKTOP_VARIANT || ''
if (!['', 'light', 'bundled', 'store'].includes(variant)) {
throw new Error(`Unknown HERMES_DESKTOP_VARIANT ${variant}. expected one of (empty), light, bundled, store`)
}
// 'store' is a Store-submission packaging identity layered on the bundled
// variant: same Electron app (displayName/appId/appNamePascal -> shared
// userData + single-instance lock with the out-of-store install), different
// MSIX package identity. The Store re-signs on submission.
const store = variant === 'store'
const light = variant === 'light'
const name = variants[store ? 'bundled' : (variant || '')]
// The electron-updater feed channel this build PUBLISHES to. A nightly
// tag (vX.Y.0-nightly.YYYYMMDDHHMMSS) writes nightly.yml / light-nightly.yml;
// stable tags write latest.yml / light.yml. Keyed on the payload tag so
// the one release workflow serves both channels — a nightly build can
// never overwrite the stable feed file, and vice versa.
const nightly = /-nightly\.20\d{6}(?:\d{6})?$/.test(process.env.HERMES_PAYLOAD_TAG || '')
/** @typedef {import("./product-identity.d.cts")} ProductIdentity */
/** @type {ProductIdentity} */
const identity = {
store,
light,
displayName: name.display,
appId: `com.nousresearch.${name.kebab}`,
// The store build never publishes to a release feed (the Store owns its
// updates); null means "no feed" for its publish config.
channel: store ? null : light ? (nightly ? 'light-nightly' : 'light') : (nightly ? 'nightly' : 'latest'),
appNamePascal: name.pascal,
msixAppIdWithOrg: `NousResearch.${name.pascal}`,
...(store
? {
storeMsix: {
// Partner Center publisher identity (the account's publisher ID) —
// validated + re-signed by the Store on submission.
identityName: 'NousResearchInc.HermesAgent',
publisher: 'CN=EE6D86E4-606F-4E38-B940-AD7248C9D519',
publisherDisplayName: 'Nous Research Inc.'
}
}
: {})
}
module.exports = identity
+27
View File
@@ -0,0 +1,27 @@
interface ProductIdentity {
/** True when this artifact is Hermes Light (remote-only client). */
light: boolean
/** True for a Store-submission build (Windows Store packaging identity). */
store: boolean
/** Display name. e.g. "Hermes Light" */
displayName: string
/** OS-level app identity. e.g. "com.nousresearch.hermes-light" */
appId: string
/** app name in pascal case. e.g. "HermesLight" */
appNamePascal: string
/** OS-level app identity w/ org prefix. e.g. "NousResearch.HermesLight" */
msixAppIdWithOrg: string
/** electron-updater feed channel this build publishes to. Stable tags:
* "latest" | "light"; nightly tags: "nightly" | "light-nightly". Null
* for a store build (the Store owns its updates; no release feed). */
channel: string | null
/** Store-submission MSIX packaging identity. Present only when `store`. */
storeMsix?: {
identityName: string
publisher: string
publisherDisplayName: string
}
}
declare const identity: ProductIdentity
export = identity
+45 -1
View File
@@ -28,7 +28,39 @@ import { sanitizeTree } from './sanitize-pe-signatures.mjs'
import { stampExeIdentity } from './set-exe-identity.mjs'
export default async function afterPack(context) {
if (context.electronPlatformName !== 'win32') {
const platform = context.electronPlatformName
if (platform === 'darwin') {
const payload = findPackedPayload(context.appOutDir, platform)
if (payload) {
const dropped = stripFetchCache(payload)
const n = relativizePayloadLinks(payload)
const entitlements = path.join(import.meta.dirname, '..', 'electron', 'entitlements.mac.inherit.plist')
const { identity, keychain } = await resolveSigningIdentity(context.packager)
const nested = signNestedChromium(payload, { entitlements, identity, keychain })
console.log(
`[after-pack] dropped ${dropped} fetch- cache dirs; relativized ${n} payload links; repaired ${nested.repaired} framework links; signed ${nested.signed} nested chromium targets` +
(identity ? ` as ${identity}` : ' (no Developer ID in the builder keychain)')
)
}
return
}
if (platform === 'linux') {
// Linux has no codesign, but the relocatable venv's bin/python* are
// ABSOLUTE symlinks onto the build runner's store interpreter, so they
// dangle once the unpacked tree moves (first-boot smoke, or a user
// installing to a different path). Rewrite them to RELATIVE symlinks
// (the target lives inside the payload) — a relative link survives
// relocation AND keeps the interpreter's real prefix resolution (a
// copied binary would fall back to the baked build prefix and lose its
// stdlib). Out-of-payload targets fail the build.
const payload = findPackedPayload(context.appOutDir, platform)
if (payload) {
const n = relativizePayloadLinks(payload)
console.log(`[after-pack] relativized ${n} payload links so the relocatable venv survives relocation`)
}
return
}
if (platform !== 'win32') {
return
}
@@ -36,6 +68,18 @@ export default async function afterPack(context) {
const exe = path.join(context.appOutDir, `${productName}.exe`)
const desktopRoot = path.resolve(import.meta.dirname, '..')
// Repair dangling PE certificate tables BEFORE electron-builder signs the
// tree. A stripped-but-still-declared signature makes signtool reject the
// file with 0x800700C1, and AppxSIP inspects every PE inside the MSIX, so
// one bad payload DLL fails the whole package. Unlike the stamp below this
// is NOT best-effort: shipping past it means shipping an unsignable bundle.
// this is a hack until https://github.com/astral-sh/python-build-standalone/pull/1217 is merged.
const { scanned, repaired } = sanitizeTree(context.appOutDir)
console.log(`[after-pack] ${scanned} PEs scanned, ${repaired.length} dangling certificate tables cleared`)
for (const file of repaired) {
console.log(` ${file}`)
}
try {
await stampExeIdentity(exe, desktopRoot)
} catch (err) {
+290
View File
@@ -0,0 +1,290 @@
// audit-bundle-arch.mjs: prove every native binary inside a packed desktop
// bundle was built for the target architecture.
//
// Why: wrong-arch binaries run FINE on the build runner (Windows-on-ARM and
// Rosetta emulate x64 silently) and only fail — or quietly burn cycles in
// emulation — on user machines. One x64 pip launcher shim inside an arm64
// payload shipped exactly this way. The build must fail loudly instead.
//
// The scan walks every file, sniffs the magic bytes (PE / ELF / Mach-O,
// including fat headers), and reports any binary whose architecture does
// not match the target. Non-binaries are skipped by content, not by file
// extension — a mis-extensioned .exe still gets caught.
//
// CLI:
// node audit-bundle-arch.mjs --arch=arm64 --root=release
//
// --root is scanned for the unpacked app dirs electron-builder produces
// (win-unpacked, win-arm64-unpacked, linux-unpacked, mac/, mac-arm64/, …).
// Auditing the unpacked tree covers the exact bytes the installer embeds:
// MSIX/DMG/AppImage add compression around it, not content.
import fs from "node:fs"
import path from "node:path"
import { isMain } from "./utils.mjs"
// ─── pure classification (unit-tested, no I/O) ─────────────────────────────
const PE_MACHINES = {
0x014c: "ia32",
0x01c0: "arm",
0x01c4: "arm",
0x8664: "x64",
0xaa64: "arm64",
0xa641: "arm64ec", // ARM64EC: arm64-ABI code, runs only on arm64 Windows
}
const ELF_MACHINES = {
0x03: "ia32",
0x28: "arm",
0x3e: "x64",
0xb7: "arm64",
}
const MACHO_CPUTYPES = {
0x01000007: "x64",
0x0100000c: "arm64",
0x00000007: "ia32",
0x0000000c: "arm",
}
/**
* Classify a buffer holding the head of a file (>= 64 bytes is plenty for
* every format except PE, whose COFF header lives at an offset named at
* 0x3c — pass headerAt for that case; classifyFile below handles it).
*
* Returns null for non-binaries, or { format, arches } where arches lists
* every architecture the file carries (>1 only for Mach-O fat binaries).
*/
export function classifyHeader(buf) {
if (buf.length >= 0x40 && buf[0] === 0x4d && buf[1] === 0x5a) {
// PE: the real machine field needs a second read at e_lfanew; caller
// resolves it. Report the format and the offset to read.
return { format: "pe", peHeaderOffset: buf.readUInt32LE(0x3c) }
}
if (buf.length >= 20 && buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
const machine = buf.readUInt16LE(18)
return { format: "elf", arches: [ELF_MACHINES[machine] || `unknown(0x${machine.toString(16)})`] }
}
if (buf.length >= 8) {
const be = buf.readUInt32BE(0)
// Thin Mach-O, either endianness.
if (be === 0xfeedface || be === 0xfeedfacf) {
const cputype = buf.readUInt32BE(4)
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
}
if (be === 0xcefaedfe || be === 0xcffaedfe) {
const cputype = buf.readUInt32LE(4)
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
}
// Fat/universal: big-endian header, one arch entry per slice.
if (be === 0xcafebabe) {
const count = buf.readUInt32BE(4)
// A Java .class file shares the magic; its "count" here is the
// bytecode version (>= 45). Real fat binaries carry a few slices.
if (count > 0 && count < 30) {
const arches = []
for (let i = 0; i < count && 8 + i * 20 + 4 <= buf.length; i++) {
const cputype = buf.readUInt32BE(8 + i * 20)
arches.push(MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`)
}
return { format: "macho-fat", arches }
}
}
}
return null
}
/**
* Resolve a PE machine code (read at peHeaderOffset+4) to an arch name.
* Split from classifyHeader so the walker can do the second read lazily.
*/
export function peArch(machineCode) {
return PE_MACHINES[machineCode] || `unknown(0x${machineCode.toString(16)})`
}
/**
* Does a classified binary satisfy the target arch?
* - exact match, always
* - macOS fat binary: any slice matching is enough (universal ships both)
* - arm64 targets accept arm64ec PEs (arm64-ABI by definition)
* Everything else — including "unknown(...)" — is a mismatch: an
* unclassifiable machine code in a shipped binary deserves a loud failure,
* not a shrug.
*/
export function archMatches(arches, targetArch) {
return arches.some((a) => a === targetArch || (targetArch === "arm64" && a === "arm64ec"))
}
/**
* The unpacked-app directories electron-builder leaves under release/ for
* a target arch. Names differ per platform: Windows/Linux use
* <platform>[-<arch>]-unpacked with the HOST-default arch unsuffixed; mac
* uses mac[-<arch>]/. Match by shape, not an exhaustive list, so a rename
* in electron-builder fails the audit (no dirs found) instead of silently
* auditing nothing.
*/
export function findUnpackedDirs(entries) {
return entries.filter((name) => /-unpacked$/.test(name) || /^mac(-[a-z0-9]+)?$/.test(name))
}
// ─── impure walker + CLI ────────────────────────────────────────────────────
function classifyFile(filePath) {
const fd = fs.openSync(filePath, "r")
try {
const head = Buffer.alloc(4096)
const n = fs.readSync(fd, head, 0, head.length, 0)
const sniffed = classifyHeader(head.subarray(0, n))
if (!sniffed) return null
if (sniffed.format !== "pe") return sniffed
// PE: read the 6 bytes at e_lfanew — "PE\0\0" + machine.
const peHead = Buffer.alloc(6)
const m = fs.readSync(fd, peHead, 0, 6, sniffed.peHeaderOffset)
if (m < 6 || peHead.readUInt32LE(0) !== 0x00004550) {
return null // MZ without a PE header: DOS-era stub or corrupt — not a shippable binary format we know.
}
return { format: "pe", arches: [peArch(peHead.readUInt16LE(4))] }
} finally {
fs.closeSync(fd)
}
}
function* walkFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue // targets are scanned as files where they live
if (entry.isDirectory()) {
yield* walkFiles(full)
} else if (entry.isFile()) {
yield full
}
}
}
/**
* Paths allowed to carry foreign-arch binaries. pip and setuptools ship
* Windows launcher STUB TEMPLATES as package data (distlib t32/t64/w32/
* w64.exe, setuptools cli*.exe / gui*.exe). They are not executed from
* here; pip copies one to build a console-script shim at install time.
* They exist on every platform and are always x86 PEs — inside an arm64
* or linux payload they are inert bytes, not a wrong-arch bug.
*
* Anchored at agent-payload (not the tree root: mac nests the payload
* under Hermes.app/Contents/Resources/). The lib segment is `Lib` on
* Windows and `lib/python3.11` elsewhere; both separators appear in
* relative paths depending on the build host.
*
* PortableGit carries .NET AnyCPU/MSIL assemblies (Git Credential Manager:
* Avalonia.*, Atlassian.*, Microsoft.*, System.*, etc.) across mingw64/bin,
* mingw64/lib, and mingw64/libexec/git-core. Their PE machine field is 0x14c
* (ia32) because .NET assemblies are format-neutral — the CLR JITs them to
* the native arch at load time. It also ships usr/libexec/getprocaddr32.exe,
* a 32-bit MSYS2 helper. The staging script already PE-probes cmd/git.exe
* itself; the bundle audit does not need to re-audit PortableGit's internal
* MSYS2/.NET layout.
*/
const EXEMPT_PATTERNS = [
// pip / setuptools launcher STUB TEMPLATES. x86 PE templates pip copies
// when it writes a console-script shim. Present on every platform.
// Live under the payload python store entry AND the relocatable venv.
/agent-payload[/\\](tools[/\\]python-[^/\\]+[/\\](Lib|lib[/\\]python[\d.]+)|venv[/\\](Lib|lib[/\\]python[\d.]+))[/\\]site-packages[/\\](setuptools|pip[/\\]_vendor[/\\]distlib)[/\\]/i,
// PortableGit internals under the payload tool store.
/agent-payload[/\\]tools[/\\]git(-[^/\\]+)?[/\\](mingw64|clangarm64|usr|cmd)[/\\]/i,
// discord.py ships x64 and x86 opus; opus.py loads by bitness.
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]discord[/\\]bin[/\\]libopus-0\.(x64|x86)\.dll$/i,
// pvporcupine ships one native lib per OS/arch in the same wheel.
// Only the matching sibling is loaded.
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]pvporcupine[/\\]lib[/\\]/i,
// debugpy ships attach helpers for every OS/arch in one wheel.
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]debugpy[/\\]_vendored[/\\]pydevd[/\\]/i,
// agent-browser ships no native win-arm64 build; on win32-arm64 the
// payload stages the x64 exe, which Windows runs under built-in
// emulation (its own postinstall falls back to x64 on arm64).
/agent-payload[/\\]tools[/\\]agent-browser-[^/\\]+[/\\]bin[/\\]agent-browser-win32-x64\.exe$/i,
// chromium / chromium-headless-shell are pm packages too: CfT publishes
// no native win-arm64 build, so on win32-arm64 the payload stages the
// win64 (x64) build and Windows runs it under emulation — the same
// choice the package code makes (PlaywrightBrowser._CFT maps win32-arm64
// to win64). Store entries are named by playwright revision only
// (chromium-<rev>, chromium_headless_shell-<rev>, dashes→underscores);
// the zips extract in place, so the x64 trees carry a -win64 segment.
// Scoping to that segment keeps linux/darwin chromium audited.
/agent-payload[/\\]tools[/\\]chromium(_headless_shell)?-[^/\\]+[/\\](chrome|chrome-headless-shell)-win64[/\\]/i,
]
export function isExemptPath(relPath) {
return EXEMPT_PATTERNS.some((p) => p.test(relPath))
}
export function auditTree(rootDir, targetArch) {
const mismatches = []
let binaries = 0
let exempted = 0
for (const file of walkFiles(rootDir)) {
let info
try {
info = classifyFile(file)
} catch {
continue // unreadable file: electron-builder would have failed on it already
}
if (!info) continue
binaries += 1
if (!archMatches(info.arches, targetArch)) {
const rel = path.relative(rootDir, file)
if (isExemptPath(rel)) {
exempted += 1
continue
}
mismatches.push({ file: rel, format: info.format, arches: info.arches })
}
}
return { binaries, exempted, mismatches }
}
function main() {
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, ...v] = a.replace(/^--/, "").split("=")
return [k, v.join("=")]
})
)
const targetArch = args.arch
const root = args.root
if (!targetArch || !root) {
console.error("usage: audit-bundle-arch.mjs --arch=<x64|arm64> --root=<release dir>")
process.exit(2)
}
const dirs = findUnpackedDirs(fs.readdirSync(root)).map((d) => path.join(root, d))
if (dirs.length === 0) {
console.error(`audit-bundle-arch: no unpacked app directory under ${root} — nothing was audited, failing`)
process.exit(1)
}
let failed = false
for (const dir of dirs) {
const { binaries, exempted, mismatches } = auditTree(dir, targetArch)
if (binaries === 0) {
console.error(`audit-bundle-arch: ${dir}: found no native binaries at all — the scan is broken, failing`)
failed = true
continue
}
if (mismatches.length > 0) {
failed = true
console.error(`audit-bundle-arch: ${dir}: ${mismatches.length} binaries do not match --arch=${targetArch}:`)
for (const m of mismatches) {
console.error(` [${m.format} ${m.arches.join("+")}] ${m.file}`)
}
} else {
const exemptNote = exempted > 0 ? ` (${exempted} exempt launcher stubs)` : ""
console.log(`audit-bundle-arch: ${dir}: ${binaries} native binaries, all ${targetArch}${exemptNote}`)
}
}
process.exit(failed ? 1 : 0)
}
if (isMain(import.meta.url)) {
main()
}
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { archMatches, classifyHeader, findUnpackedDirs, isExemptPath, peArch } from '../scripts/audit-bundle-arch.mjs'
// ─── header builders: the smallest buffers each format needs ───────────────
function elfHeader(machine) {
const buf = Buffer.alloc(64)
buf.write('\x7fELF', 0, 'latin1')
buf.writeUInt16LE(machine, 18)
return buf
}
function machoThin(cputype, { swapped = false } = {}) {
const buf = Buffer.alloc(64)
if (swapped) {
buf.writeUInt32BE(0xcffaedfe, 0) // magic as stored little-endian on disk
buf.writeUInt32LE(cputype, 4)
} else {
buf.writeUInt32BE(0xfeedfacf, 0)
buf.writeUInt32BE(cputype, 4)
}
return buf
}
function machoFat(cputypes) {
const buf = Buffer.alloc(8 + cputypes.length * 20)
buf.writeUInt32BE(0xcafebabe, 0)
buf.writeUInt32BE(cputypes.length, 4)
cputypes.forEach((t, i) => buf.writeUInt32BE(t, 8 + i * 20))
return buf
}
function mzStub(peOffset) {
const buf = Buffer.alloc(0x40)
buf.write('MZ', 0, 'latin1')
buf.writeUInt32LE(peOffset, 0x3c)
return buf
}
// ─── classifyHeader ─────────────────────────────────────────────────
test('classifyHeader names the arch for each executable format', () => {
assert.deepEqual(classifyHeader(elfHeader(0x3e)).arches, ['x64'])
assert.deepEqual(classifyHeader(elfHeader(0xb7)).arches, ['arm64'])
assert.deepEqual(classifyHeader(machoThin(0x0100000c)).arches, ['arm64'])
assert.deepEqual(classifyHeader(machoThin(0x01000007, { swapped: true })).arches, ['x64'])
assert.deepEqual(classifyHeader(machoFat([0x01000007, 0x0100000c])).arches, ['x64', 'arm64'])
})
test('classifyHeader defers PE to the offset named in the MZ stub', () => {
const sniffed = classifyHeader(mzStub(0x180))
assert.equal(sniffed.format, 'pe')
assert.equal(sniffed.peHeaderOffset, 0x180)
// The machine code itself resolves through peArch.
assert.equal(peArch(0x8664), 'x64')
assert.equal(peArch(0xaa64), 'arm64')
assert.equal(peArch(0xa641), 'arm64ec')
assert.match(peArch(0xbeef), /unknown/)
})
test('classifyHeader skips non-binaries, tiny files, and Java class files', () => {
assert.equal(classifyHeader(Buffer.from('#!/bin/sh\necho hi\n')), null)
assert.equal(classifyHeader(Buffer.from('MZ')), null) // too short to carry a PE offset
assert.equal(classifyHeader(Buffer.alloc(0)), null)
// Java .class: same magic as a fat Mach-O, giant "slice count" (version).
const javaClass = Buffer.alloc(16)
javaClass.writeUInt32BE(0xcafebabe, 0)
javaClass.writeUInt32BE(65, 4)
assert.equal(classifyHeader(javaClass), null)
})
// ─── archMatches ─────────────────────────────────────────────────
test('archMatches: exact match, universal slices, arm64ec, and rejections', () => {
assert.ok(archMatches(['arm64'], 'arm64'))
assert.ok(archMatches(['x64', 'arm64'], 'arm64')) // universal binary covers the target
assert.ok(archMatches(['arm64ec'], 'arm64')) // arm64-ABI by definition
assert.ok(!archMatches(['x64'], 'arm64')) // the shipped-x64-shim bug this audit exists for
assert.ok(!archMatches(['arm64ec'], 'x64')) // arm64ec does not run on x64 hosts
assert.ok(!archMatches(['unknown(0xbeef)'], 'x64')) // unclassifiable ships nowhere
})
// ─── findUnpackedDirs ─────────────────────────────────────────────────
test('findUnpackedDirs matches electron-builder output shapes only', () => {
const dirs = findUnpackedDirs([
'win-unpacked', 'win-arm64-unpacked', 'linux-unpacked', 'linux-arm64-unpacked',
'mac', 'mac-arm64',
'builder-debug.yml', 'Hermes-0.20.0.exe', 'latest.yml', '.icon-ico'
])
assert.deepEqual(dirs, [
'win-unpacked', 'win-arm64-unpacked', 'linux-unpacked', 'linux-arm64-unpacked',
'mac', 'mac-arm64'
])
})
// ─── the git exemption is PortableGit-shaped, not git-shaped ───────────────
test('PortableGit internals stay exempt under the payload store', () => {
// .NET assemblies report ia32 because they are format-neutral.
for (const relPath of [
'resources/agent-payload/tools/git-2.53.0-win32-x64/mingw64/bin/Avalonia.dll',
'resources\\agent-payload\\tools\\git-2.53.0-win32-x64\\mingw64\\libexec\\git-core\\GitHub.dll',
'resources\\agent-payload\\tools\\git-2.53.0-win32-x64\\usr\\libexec\\getprocaddr32.exe',
'resources/agent-payload/tools/git-2.54.0-win32-arm64/clangarm64/libexec/git-core/msalruntime.dll'
]) {
assert.equal(isExemptPath(relPath), true, relPath)
}
})
test('a POSIX git store entry is audited', () => {
// posix uses system git; a linux/darwin git store has no mingw64/usr/cmd
// segment, so a wrong-arch binary there must fail the audit.
for (const relPath of [
'resources/agent-payload/tools/git-2.53.0-linux-x64/bin/git',
'resources/agent-payload/tools/git-2.53.0-darwin-arm64/libexec/git-core/git-remote-https'
]) {
assert.equal(isExemptPath(relPath), false, relPath)
}
})
test('pip and setuptools launcher stubs are exempt in the store and the venv', () => {
for (const relPath of [
'resources/agent-payload/tools/python-3.11.16+20260814-linux-x64/lib/python3.11/site-packages/pip/_vendor/distlib/t32.exe',
'resources/agent-payload/tools/python-3.11.16+20260814-linux-x64/lib/python3.11/site-packages/setuptools/cli.exe',
'resources/agent-payload/venv/lib/python3.11/site-packages/setuptools/cli-32.exe',
'resources/agent-payload/venv/Lib/site-packages/setuptools/cli.exe'
]) {
assert.equal(isExemptPath(relPath), true, relPath)
}
})
test('discord opus and pvporcupine multi-arch siblings are exempt', () => {
for (const relPath of [
'resources/agent-payload/venv/lib/python3.11/site-packages/discord/bin/libopus-0.x86.dll',
'resources/agent-payload/venv/lib/python3.11/site-packages/discord/bin/libopus-0.x64.dll',
'resources/agent-payload/venv/lib/python3.11/site-packages/pvporcupine/lib/mac/arm64/libpv_porcupine.dylib',
'resources/agent-payload/venv/lib/python3.11/site-packages/pvporcupine/lib/raspberry-pi/arm11/libpv_porcupine.so',
'resources/agent-payload/venv/lib/python3.11/site-packages/debugpy/_vendored/pydevd/pydevd_attach_to_process/attach_linux_amd64.so',
'resources/agent-payload/venv/lib/python3.11/site-packages/debugpy/_vendored/pydevd/pydevd_attach_to_process/inject_dll_x86.exe'
]) {
assert.equal(isExemptPath(relPath), true, relPath)
}
})
test('the emulated x64 agent-browser exe is exempt in a win32-arm64 payload', () => {
for (const relPath of [
'resources/agent-payload/tools/agent-browser-0.35.1-win32-arm64/bin/agent-browser-win32-x64.exe',
'resources\\agent-payload\\tools\\agent-browser-0.26.0-win32-arm64\\bin\\agent-browser-win32-x64.exe'
]) {
assert.equal(isExemptPath(relPath), true, relPath)
}
// A non-chromium x64 binary in the payload store still fails: the
// chromium exemption is scoped to the store entries' -win64 trees, not
// to "any x64 exe under tools/".
assert.equal(isExemptPath('resources/agent-payload/tools/something-1.0-win32-arm64/bin/thing.exe'), false)
})
test('the emulated x64 chromium trees are exempt in a win32-arm64 payload', () => {
// PlaywrightBrowser._CFT maps win32-arm64 to the CfT win64 (x64) build;
// store entries are named by playwright revision (dashes→underscores)
// and the zip extracts in place, so the x64 trees sit under
// chrome-win64 / chrome-headless-shell-win64 inside the entry.
for (const relPath of [
'resources/agent-payload/tools/chromium-1208/chrome-win64/chrome.exe',
'resources\\agent-payload\\tools\\chromium-1208\\chrome-win64\\chrome.dll',
'resources/agent-payload/tools/chromium_headless_shell-1208/chrome-headless-shell-win64/chrome-headless-shell.exe'
]) {
assert.equal(isExemptPath(relPath), true, relPath)
}
// The linux/darwin chromium trees stay audited: no -win64 segment.
assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-linux/chrome'), false)
assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-mac-arm64/chrome'), false)
})
test('a random payload binary is not exempt', () => {
assert.equal(isExemptPath('resources/agent-payload/hermes-agent/something.exe'), false)
})
+34 -10
View File
@@ -2,9 +2,20 @@
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
* deterministic across environments.
*
* Also guarantees build/agent-payload exists: extraResources copies it on
* every build, and electron-builder's behavior for a missing `from` varies
* between versions. A bundled build stages the real payload there first
* (`hermes pm bundle --out apps/desktop/build/agent-payload`); anything else
* gets a stub manifest with external:true, which resolvePayload() treats as
* "no payload" so the backend resolver falls through to the runtime rungs.
*
* Also stages the MSIX build-time assets (the build/appx icon set and the
* build/msix-extensions.xml fragment the config points customExtensionsPath
* at). These MUST happen here, in the hook electron-builder calls at build
* time — not in electron-builder.config.cjs at require time, or every
* typecheck/test import of the config would write files.
*/
import fs from 'node:fs'
import path from 'node:path'
@@ -20,6 +31,17 @@ const {
} = require('../product-identity.cjs')
export default async function beforeBuild() {
stageMsixAssets()
writeMsixExtensions()
const payloadDir = path.join(import.meta.dirname, '..', 'build', 'agent-payload')
const manifest = path.join(payloadDir, 'manifest.json')
if (!fs.existsSync(manifest)) {
fs.mkdirSync(payloadDir, { recursive: true })
fs.writeFileSync(manifest, JSON.stringify({ schema: 1, external: true }, null, 2) + '\n')
}
return false
}
@@ -95,17 +117,19 @@ ${aliases}`
export function appExecutionAliasExtensions(launchers = CLI_LAUNCHER_SPECS.map((s) => s.name)) {
const bs = String.fromCharCode(92)
const executable = (name) => ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(bs)
return launchers
.map(
(name) => `<uap5:Extension
// ONE windows.appExecutionAlias extension per package — makeappx rejects a
// second one with the opaque 0x80080204 (A/B-verified against the 26100
// kit). Every launcher alias rides in the same extension's AppExecutionAlias;
// the Executable attribute names the exe that serves the aliases.
return `<uap5:Extension
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
Category="windows.appExecutionAlias"
Executable="${executable(name)}"
Executable="${executable(launchers[0])}"
EntryPoint="Windows.FullTrustApplication">
<uap5:AppExecutionAlias>
<uap5:ExecutionAlias Alias="${name}.exe" />
${launchers
.map((name) => ` <uap5:ExecutionAlias Alias="${name}.exe" />`)
.join('\n')}
</uap5:AppExecutionAlias>
</uap5:Extension>`
)
.join('\n')
}
+27 -2
View File
@@ -10,9 +10,12 @@
// `electron` and `node-pty` are external (provided by the runtime / staged
// separately via stage-native-deps).
import { build } from 'esbuild'
import { createRequire } from 'node:module'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdirSync } from 'node:fs'
import { mkdirSync, readFileSync } from 'node:fs'
const require = createRequire(import.meta.url)
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
@@ -29,9 +32,31 @@ const external = ['electron', 'node-pty', 'get-windows', 'fs']
// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone
// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working.
const isDev = process.argv.includes('--dev')
// The install stamp is baked INTO the bundle: the define below sets the
// __HERMES_INSTALL_STAMP__ global to the stamp OBJECT literal (the JSON
// text is a valid JS expression, so no string round-trip). A baked
// constant cannot be missing, stale, or edited after signing.
// `npm run build` writes build/install-stamp.json immediately before this
// script runs, so a missing file here is a broken build, not a thin one.
// Dev bundles bake nothing — install-stamp.ts's typeof guard yields null,
// because a dev run has no artifact to be truthful about (a stale stamp
// from a previous build would lie about provenance).
function bakedInstallStamp() {
const raw = readFileSync(resolve(root, 'build/install-stamp.json'), 'utf8')
JSON.parse(raw) // fail the build on malformed output, not first launch
return raw
}
const define = isDev
? {}
: { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) }
: {
'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true),
'__HERMES_INSTALL_STAMP__': bakedInstallStamp(),
// The product identity (name object, appId, deep-link scheme)
// baked the same way electron-builder gets it
'__HERMES_PRODUCT_IDENTITY__': JSON.stringify(require('../product-identity.cjs')),
}
// Bundle main.ts → dist/electron-main.mjs
await build({
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Check for an App Installer update for the running MSIX package.
The out-of-store desktop installs are App Installer owned: the OS registered
the .appinstaller URI as the package's update source at install, so the
OS can tell us whether a newer package version is available. The desktop
shows its own prompt + tears down before the OS applies the swap.
Run with the BUNDLED payload python (the winrt package ships there):
<payload>/tools/<python-entry>/python.exe scripts/check-appinstaller-update.py
Exit codes:
0 no update available (or no App Installer source — nothing to do)
2 update available (the caller decides whether to prompt/teardown)
1 error (update availability unknown; the caller reports it honestly)
"""
import json
import sys
def main() -> int:
try:
from winrt.windows.applicationmodel import Package, PackageUpdateAvailability
from winrt.windows.management.deployment import PackageManager
except ImportError as exc:
# The winrt module is absent (older payload without the dep): the
# update check cannot run. Report unknown, not "no update".
print(json.dumps({"available": None, "error": f"winrt import failed: {exc}"}))
return 1
try:
package = Package.current()
except Exception as exc: # noqa: BLE001 — the API raises on non-packaged runs
# Not running as a packaged app (dev run) — nothing to check.
print(json.dumps({"available": False, "reason": "not-packaged"}))
return 0
package_family_name = package.id.family_name
manager = PackageManager()
try:
result = manager.check_package_update_availability_async(package_family_name).get()
except Exception as exc: # noqa: BLE001
print(json.dumps({"available": None, "error": f"check failed: {exc}"}))
return 1
availability = PackageUpdateAvailability(result.availability)
# Available / Required both mean "a newer package exists on the source".
available = availability in (
PackageUpdateAvailability.AVAILABLE,
PackageUpdateAvailability.REQUIRED,
)
print(json.dumps({"available": available, "availability": str(availability)}))
return 2 if available else 0
if __name__ == "__main__":
sys.exit(main())
+13 -11
View File
@@ -87,24 +87,26 @@ test('win wrapper rejects values that still contain placeholders', () => {
assert.throws(() => renderWinWrapper({ module: '__HERMES_REPO_REL__', func: 'main' }, RELS.relRepo, RELS.relSite))
})
test('MSIX: one uap5 alias Extension per launcher exe, Executable naming that exe', () => {
test('MSIX: ONE uap5 alias Extension naming every launcher alias', () => {
const xml = appExecutionAliasExtensions()
const blocks = xml.match(/<uap5:Extension\b/g) ?? []
assert.equal(blocks.length, 3)
// makeappx rejects a second windows.appExecutionAlias Extension with the
// opaque 0x80080204 — all launcher aliases must ride in ONE block.
assert.equal(blocks.length, 1)
// The block's Executable names the exe that serves the aliases (first launcher).
const first = ['app', 'resources', 'agent-payload', 'bin', `${CLI_LAUNCHER_SPECS[0].name}.exe`].join('\\')
assert.ok(xml.includes(`Executable="${first}"`), 'Executable must name the first launcher exe')
// Every launcher exe gets exactly one ExecutionAlias inside the block.
for (const spec of CLI_LAUNCHER_SPECS) {
const expected = ['app', 'resources', 'agent-payload', 'bin', `${spec.name}.exe`].join('\\')
assert.ok(
xml.includes(`Executable="${expected}"`),
`no alias Extension names ${expected}`
)
// Backslashes: MSIX Executable must be manifest-backslash form.
assert.ok(!xml.includes(`${expected}/`), 'forward slashes are not manifest form')
assert.ok(
xml.includes(`<uap5:ExecutionAlias Alias="${spec.name}.exe" />`),
`no ExecutionAlias for ${spec.name}.exe`
)
}
// The old rust shim's single-exe-argv[0]-dispatch shape is gone: each
// block serves exactly ONE alias.
for (const block of (xml.match(/<uap5:Extension[\s\S]*?<\/uap5:Extension>/g) ?? [])) {
assert.equal((block.match(/<uap5:ExecutionAlias /g) ?? []).length, 1)
}
assert.equal((xml.match(/<uap5:ExecutionAlias /g) ?? []).length, CLI_LAUNCHER_SPECS.length)
})
test('light variant emits no alias fragments', () => {
+144
View File
@@ -0,0 +1,144 @@
// Bound how many fs.open calls are in flight at once, process-wide.
//
// WHY THIS EXISTS
//
// @electron/osx-sign decides what to sign by walking the whole .app
// (dist/util.js::walk). The walk recurses with
// `Promise.all(children.map(...))` and no concurrency bound, and every
// regular file it reaches is probed by isbinaryfile, which opens a
// descriptor. Peak descriptors therefore track the size of the tree, not
// any fixed budget.
//
// The bundled payload makes that fatal. site-packages/lark_oapi alone is
// 11,112 files, so signing died with:
//
// EMFILE: too many open files, open
// '.../agent-payload/site-packages/lark_oapi/api/im/v1/model/reaction.py'
//
// Raising `ulimit -n` also clears it, but only moves the ceiling: the
// walk still asks for as many descriptors as there are files, so the
// next payload growth hits the new limit. This caps the demand instead.
//
// WHAT IT PATCHES
//
// `fs.open` (callback form) and `fs.promises.open`. That covers the
// paths that actually consume descriptors here:
// - isbinaryfile captures `promisify(fs.open)` at module load, so the
// patch must be installed BEFORE it is required — hence --require.
// - fs.createReadStream routes through the public fs.open (verified).
//
// Deliberately NOT patched: openSync. A synchronous open cannot be
// queued without blocking the loop that would drain the queue, and it is
// self-limiting anyway — one at a time, by definition.
//
// WHY IT CANNOT DEADLOCK
//
// The slot is released when the open() call SETTLES, not when the
// descriptor is closed. A task holding ten descriptors occupies zero
// slots while it works, so "hold one, open another" always makes
// progress. Gating the descriptor's lifetime instead does deadlock —
// measured: 4 tasks each holding 1 fd and awaiting a second, under
// cap=2, never completes.
//
// Failures propagate unchanged: a rejected open releases its slot in
// `finally` and the original error reaches the caller. This changes
// timing only, never behavior.
//
// HERMES_FS_OPEN_LIMIT overrides the computed cap; 0 disables the patch.
'use strict'
const fs = require('fs')
// The cap must sit well BELOW the process's fd limit, not near it. Each
// queued open is held across the caller's read+close (osx-sign's walk
// does exactly that), so `LIMIT` descriptors can be live at once, on top
// of the fds node already holds for stdio, the module loader, sockets
// and spawned children. Measured against the real walk: cap 100 fails at
// a 64 limit and passes at 256; cap 16 passes at 64.
//
// So: a quarter of the soft limit, clamped to a sane band. On the macOS
// default of 256 that is 64; on a raised limit it grows, keeping the
// walk fast; on a tiny limit it shrinks instead of guaranteeing EMFILE.
function defaultLimit() {
const override = Number(process.env.HERMES_FS_OPEN_LIMIT)
if (Number.isFinite(override) && override >= 0) return override
let soft = 0
try {
soft = process.report.getReport().userLimits.open_files.soft
} catch {
soft = 0
}
// `unlimited` reports as -1 (or a huge number); either way the clamp holds.
if (!Number.isFinite(soft) || soft <= 0) soft = 256
return Math.max(8, Math.min(512, Math.floor(soft / 4)))
}
const LIMIT = defaultLimit()
// A guard against double-install (nested spawns inheriting NODE_OPTIONS).
if (!globalThis.__hermesFsOpenLimited && LIMIT > 0) {
globalThis.__hermesFsOpenLimited = true
let active = 0
/** @type {Array<() => void>} */
const queue = []
const pump = () => {
while (active < LIMIT && queue.length > 0) {
active += 1
queue.shift()()
}
}
const release = () => {
active -= 1
pump()
}
// ── fs.open (callback form) ───────────────────────────────────────
const realOpen = fs.open
fs.open = function open(...args) {
const cb = args[args.length - 1]
if (typeof cb !== 'function') {
// No callback: not the async form we can queue (and node throws on
// it anyway). Hand it straight to the real implementation.
return realOpen.apply(this, args)
}
const rest = args.slice(0, -1)
queue.push(() => {
realOpen.call(fs, ...rest, function (...cbArgs) {
// Release BEFORE the callback runs: the descriptor is open, this
// call is done, and the caller may synchronously open another.
release()
cb.apply(this, cbArgs)
})
})
pump()
return undefined
}
// promisify(fs.open) reads these off the function it is handed.
Object.defineProperty(fs.open, 'name', { value: 'open' })
if (realOpen[Symbol.for('nodejs.util.promisify.custom')]) {
fs.open[Symbol.for('nodejs.util.promisify.custom')] =
realOpen[Symbol.for('nodejs.util.promisify.custom')]
}
// ── fs.promises.open ──────────────────────────────────────────────
const realPromisesOpen = fs.promises.open
fs.promises.open = function open(...args) {
return new Promise((resolve, reject) => {
queue.push(() => {
realPromisesOpen
.apply(fs.promises, args)
.then(resolve, reject)
.finally(release)
})
pump()
})
}
if (process.env.HERMES_FS_OPEN_LIMIT_DEBUG) {
console.log(`[fs-open-limit] fs.open concurrency capped at ${LIMIT}`)
}
}
+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
// ─── fs-open-limit.cjs ─────────────────────────────────────────────
//
// The preload caps concurrent fs.open calls so @electron/osx-sign's
// unbounded walk cannot exhaust the descriptor table (EMFILE during mac
// signing). These tests run REAL node processes against REAL trees with a
// REAL low `ulimit -n`, because the whole contract is "does this survive
// a descriptor limit" — a mocked fs would prove nothing.
const PRELOAD = path.join(import.meta.dirname, 'fs-open-limit.cjs')
const POSIX = process.platform !== 'win32'
/** Run `body` in a child node process, optionally with the preload + a soft fd limit. */
function runNode(body, { preload = false, ulimit = null, env = {} } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsopen-test-'))
const script = path.join(dir, 'body.mjs')
fs.writeFileSync(script, body)
const nodeArgs = preload ? ['--require', PRELOAD, script] : [script]
// ulimit needs a shell; keep the arg vector explicit either way.
const cmd = ulimit
? `ulimit -n ${ulimit}; exec "$0" ${nodeArgs.map((a) => `'${a}'`).join(' ')}`
: null
const res = cmd
? spawnSync('/bin/sh', ['-c', cmd, process.execPath], { encoding: 'utf8', env: { ...process.env, ...env } })
: spawnSync(process.execPath, nodeArgs, { encoding: 'utf8', env: { ...process.env, ...env } })
fs.rmSync(dir, { recursive: true, force: true })
return res
}
/** Build a tree big enough to exhaust a low fd limit, like the payload does. */
function mkTree(fileCount) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fsopen-tree-'))
for (let i = 0; i < fileCount; i++) {
const d = path.join(root, `pkg${i % 40}`, `mod${i % 15}`)
fs.mkdirSync(d, { recursive: true })
fs.writeFileSync(path.join(d, `f${i}.py`), '# python\n')
}
return root
}
/** The osx-sign walk, verbatim in shape: Promise.all + an open per file. */
const WALK_BODY = (root) => `
import fs from 'node:fs'
import path from 'node:path'
async function walk(dir) {
const children = await fs.promises.readdir(dir)
return Promise.all(children.map(async (child) => {
const p = path.resolve(dir, child)
const st = await fs.promises.lstat(p)
if (st.isFile()) {
const fh = await fs.promises.open(p, 'r')
try { const b = Buffer.alloc(4); await fh.read(b, 0, 4, 0); return null }
finally { await fh.close() }
}
if (st.isDirectory() && !st.isSymbolicLink()) return walk(p)
return null
}))
}
await walk(${JSON.stringify(root)})
console.log('WALK_OK')
`
test('the unbounded walk hits EMFILE under a low fd limit', () => {
if (!POSIX) return
// The bug, reproduced. If this ever stops failing, the test below is
// no longer proving anything and the preload could be silently useless.
const tree = mkTree(3000)
try {
const res = runNode(WALK_BODY(tree), { ulimit: 64 })
assert.notEqual(res.status, 0, 'expected the unbounded walk to fail')
assert.match(res.stderr, /EMFILE/, `expected EMFILE, got: ${res.stderr.slice(0, 300)}`)
} finally {
fs.rmSync(tree, { recursive: true, force: true })
}
})
test('the preload lets the same walk finish under the same limit', () => {
if (!POSIX) return
const tree = mkTree(3000)
try {
const res = runNode(WALK_BODY(tree), { preload: true, ulimit: 64 })
assert.equal(res.status, 0, `walk failed with preload: ${res.stderr.slice(0, 400)}`)
assert.match(res.stdout, /WALK_OK/)
} finally {
fs.rmSync(tree, { recursive: true, force: true })
}
})
test('both fs.open forms are queued, so a huge fan-out survives', () => {
if (!POSIX) return
// isbinaryfile uses promisify(fs.open) (the callback form) while the
// walk itself uses fs.promises.open — the patch has to cover both, or
// half the demand escapes the queue.
//
// Each iteration opens AND CLOSES before the next needs a descriptor,
// which is the shape the cap can actually satisfy. (Holding 400
// descriptors live at once is impossible under a 64 fd limit no matter
// how the opens are scheduled — an earlier version of this test asked
// for exactly that and failed for its own reasons, not the patch's.)
const body = `
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peak-'))
for (let i = 0; i < 400; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
// promise form: 400 concurrent open+close cycles.
await Promise.all(Array.from({length: 400}, async (_, i) => {
const fh = await fs.promises.open(path.join(dir, 'f' + i), 'r')
await fh.close()
}))
// callback form: same fan-out, through promisify like isbinaryfile does.
import { promisify } from 'node:util'
const openAsync = promisify(fs.open)
const closeAsync = promisify(fs.close)
await Promise.all(Array.from({length: 400}, async (_, i) => {
const fd = await openAsync(path.join(dir, 'f' + i), 'r')
await closeAsync(fd)
}))
fs.rmSync(dir, { recursive: true, force: true })
console.log('BOTH_FORMS_OK')
`
const res = runNode(body, { preload: true, ulimit: 64 })
assert.equal(res.status, 0, `failed: ${res.stderr.slice(0, 400)}`)
assert.match(res.stdout, /BOTH_FORMS_OK/)
})
test('the callback fan-out DOES fail without the preload', () => {
if (!POSIX) return
// Guards the test above: if 400 open+close cycles were survivable
// unpatched, it would prove nothing about the queue.
const body = `
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
const openAsync = promisify(fs.open)
const closeAsync = promisify(fs.close)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peak-raw-'))
for (let i = 0; i < 400; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
await Promise.all(Array.from({length: 400}, async (_, i) => {
const fd = await openAsync(path.join(dir, 'f' + i), 'r')
await closeAsync(fd)
}))
console.log('UNEXPECTEDLY_OK')
`
const res = runNode(body, { ulimit: 64 })
assert.notEqual(res.status, 0, 'unpatched fan-out should exhaust the fd table')
assert.match(res.stderr, /EMFILE/)
})
test('open errors still reach the caller unchanged', () => {
const body = `
import fs from 'node:fs'
let promiseErr = null, cbErr = null
try { await fs.promises.open('/definitely/not/here', 'r') } catch (e) { promiseErr = e }
await new Promise((res) => fs.open('/definitely/not/here', 'r', (e) => { cbErr = e; res() }))
if (promiseErr?.code !== 'ENOENT') throw new Error('promise form lost the error: ' + promiseErr?.code)
if (cbErr?.code !== 'ENOENT') throw new Error('callback form lost the error: ' + cbErr?.code)
console.log('ERRORS_PROPAGATE')
`
const res = runNode(body, { preload: true })
assert.equal(res.status, 0, res.stderr.slice(0, 400))
assert.match(res.stdout, /ERRORS_PROPAGATE/)
})
test('a rejected open releases its slot instead of wedging the queue', () => {
// If the failure path forgot to release, the queue drains to zero
// permanently and this hangs rather than failing.
const body = `
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rej-'))
fs.writeFileSync(path.join(dir, 'real'), 'x')
// 300 failures (more than the cap of 100) then a real open.
await Promise.all(Array.from({length: 300}, () =>
fs.promises.open(path.join(dir, 'missing'), 'r').catch(() => null)))
const fh = await fs.promises.open(path.join(dir, 'real'), 'r')
await fh.close()
fs.rmSync(dir, { recursive: true, force: true })
console.log('QUEUE_STILL_LIVE')
`
const res = runNode(body, { preload: true })
assert.equal(res.status, 0, res.stderr.slice(0, 400))
assert.match(res.stdout, /QUEUE_STILL_LIVE/)
})
test('holding descriptors while opening more cannot deadlock', () => {
if (!POSIX) return
// The slot is released when open() SETTLES, not when the fd closes, so a
// task holding many descriptors occupies no slots. Gating the fd lifetime
// instead deadlocks — that is the design this asserts against.
const body = `
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hold-'))
for (let i = 0; i < 600; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
// 200 tasks (> the cap of 100) each holding one fd, then asking for two more.
const held = []
await Promise.all(Array.from({length: 200}, (_, i) => (async () => {
const a = await fs.promises.open(path.join(dir, 'f' + i), 'r')
const b = await fs.promises.open(path.join(dir, 'f' + (i + 200)), 'r')
const c = await fs.promises.open(path.join(dir, 'f' + (i + 400)), 'r')
held.push(a, b, c)
})()))
for (const h of held) await h.close()
fs.rmSync(dir, { recursive: true, force: true })
console.log('NO_DEADLOCK')
`
const res = runNode(body, { preload: true, ulimit: 1024 })
assert.equal(res.status, 0, res.stderr.slice(0, 400))
assert.match(res.stdout, /NO_DEADLOCK/)
})
test('HERMES_FS_OPEN_LIMIT=0 disables the patch', () => {
// An escape hatch that must genuinely leave fs.open alone.
const body = `
import fs from 'node:fs'
if (fs.open.name !== 'open') throw new Error('unexpected name')
if (globalThis.__hermesFsOpenLimited) throw new Error('patch installed despite limit 0')
console.log('DISABLED_OK')
`
const res = runNode(body, { preload: true, env: { HERMES_FS_OPEN_LIMIT: '0' } })
assert.equal(res.status, 0, res.stderr.slice(0, 400))
assert.match(res.stdout, /DISABLED_OK/)
})
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
// gen-appinstaller.mjs — generate the Windows App Installer (.appinstaller)
// file for an out-of-store MSIX channel feed.
//
// The out-of-store distribution is App Installer owned: each stable/nightly
// channel dir under the feed host holds a universal .msixbundle plus an
// .appinstaller that (a) installs the bundle and (b) records the .appinstaller
// URI as the package's update source, so the OS can re-check it on launch.
//
// The identity comes from product-identity.cjs via scripts/msix-shared.mjs
// (the SAME single derivation as the package manifest), so the
// .appinstaller's MainPackage Name/Publisher always match the bundle's
// manifest. `store` has no appinstaller (the Store owns its distribution).
//
// Pure buildAppInstaller() lives in scripts/msix-shared.mjs and is
// unit-tested; this module is the CLI wrapper:
// node apps/desktop/scripts/gen-appinstaller.mjs --out <file> --base-url <url>
// Reads HERMES_DESKTOP_VARIANT (bundled|light), HERMES_PAYLOAD_TAG (channel)
// and package.json (version) like the rest of the build.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { appIdentity, buildAppInstaller } from '../../../scripts/msix-shared.mjs'
const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const isCli = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
if (isCli) {
// node strips the first '--' (and an immediately-following option) for its
// own use; --out lands as a BARE arg. Parse space-separated flag pairs,
// not --flag=value, so the CLI survives that mangling.
const args = process.argv.slice(2)
const flagValue = (name) => {
for (let i = 0; i < args.length - 1; i += 1) {
if (args[i] === name) return args[i + 1]
}
return undefined
}
const out = flagValue('--out')
const baseUrl = flagValue('--base-url') || process.env.CLOUDFLARE_R2_PUBLIC_URL
if (!out || !baseUrl) {
console.error('[gen-appinstaller] --out=<file> and --base-url=<url> (or CLOUDFLARE_R2_PUBLIC_URL) are required')
process.exit(1)
}
const { identity, version, name } = appIdentity(desktop, process.env.HERMES_PAYLOAD_TAG)
if (identity.store) {
console.error('[gen-appinstaller] a store build has no App Installer feed (the Store owns distribution)')
process.exit(1)
}
const nightly = /-nightly/.test(process.env.HERMES_PAYLOAD_TAG || '')
const variantDir = identity.light ? 'light/' : ''
const ch = nightly ? 'nightly' : 'stable'
const channelPath = `win32/${variantDir}${ch}`
const xml = buildAppInstaller({
baseUrl: String(baseUrl).replace(/\/+$/, ''),
variantChannelPath: channelPath,
identityName: identity.msixAppIdWithOrg,
version,
bundleFilename: `${name}-${version}-win.msixbundle`
})
fs.mkdirSync(path.dirname(out), { recursive: true })
fs.writeFileSync(out, xml)
console.log(`[gen-appinstaller] wrote ${out} (${channelPath}/${name}-${version}-win.msixbundle)`)
}
@@ -0,0 +1,54 @@
// gen-appinstaller — the .appinstaller document for an out-of-store channel.
// The identity inside must match the package manifest (same derivation), and
// the bundle/package URLs must resolve under the feed host.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { OUT_OF_STORE_PUBLISHER, buildAppInstaller } from '../../../scripts/msix-shared.mjs'
describe('buildAppInstaller', () => {
const base = {
baseUrl: 'https://updates.example.com',
variantChannelPath: 'win32/stable',
identityName: 'NousResearch.HermesBundled',
version: '0.3.0.0',
bundleFilename: 'HermesBundled-0.3.0.0-win.msixbundle'
}
test('pins the same publisher as the out-of-store manifest (ATS cert subject)', () => {
const xml = buildAppInstaller(base)
assert.match(xml, /Publisher="CN=Nous Research Inc\., O=Nous Research Inc\., L=Austin, S=Texas, C=US"/)
assert.equal(OUT_OF_STORE_PUBLISHER, 'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US')
})
test('MainPackage URI and the canonical Uri point at the bundle + appinstaller under the feed dir', () => {
const xml = buildAppInstaller(base)
assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/stable\/HermesBundled-0\.3\.0\.0-win\.msixbundle"/)
// The AppInstaller's own Uri is the bundle URL with .appinstaller swapped in.
assert.match(xml, new RegExp(`^<AppInstaller\\n Uri="[^"]+/win32/stable/${base.bundleFilename.replace(/\.msixbundle$/, '.appinstaller')}"`, 'm'))
})
test('MainPackage Name equals the package identity; version matches everywhere', () => {
const xml = buildAppInstaller(base)
assert.match(xml, /Name="NousResearch\.HermesBundled"/)
const versionCount = (xml.match(/Version="0\.3\.0\.0"/g) || []).length
// AppInstaller Version + MainPackage Version = 2 occurrences.
assert.equal(versionCount, 2)
})
test('UpdateSettings keeps the OS prompt off (the in-app checker owns the prompt)', () => {
const xml = buildAppInstaller(base)
assert.match(xml, /<OnLaunch HoursBetweenUpdateChecks="12" ShowPrompt="false" \/>/)
})
test('a variant channel path with a trailing slash still resolves under the host', () => {
const xml = buildAppInstaller({ ...base, variantChannelPath: 'win32/nightly/' })
assert.match(xml, /https:\/\/updates\.example\.com\/win32\/nightly\//)
})
test('reserved XML characters in identity values are escaped', () => {
const xml = buildAppInstaller({ ...base, identityName: 'A&B<App>' })
assert.match(xml, /Name="A&amp;B&lt;App&gt;"/)
})
})
@@ -12,13 +12,19 @@ import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, test } from 'vitest'
import hostedGitInfo from 'hosted-git-info'
const require = createRequire(import.meta.url)
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const desktopPkg = require(path.join(desktopDir, 'package.json'))
const { getPublishConfigs } = require('app-builder-lib/out/publish/PublishManager.js')
const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.js')
// app-builder-lib@27 tightened its "exports" map (deep subpaths are no
// longer exported) and moved out/ -> dist/. Resolve the package root from
// its main entry, then reach the real resolver files by absolute path so
// the behavior under test is the actual installed library.
const appBuilderLibDir = path.dirname(path.dirname(require.resolve('app-builder-lib')))
const { getPublishConfigs } = require(path.join(appBuilderLibDir, 'dist/publish/PublishManager.js'))
const { getRepositoryInfo } = require(path.join(appBuilderLibDir, 'dist/util/repositoryInfo.js'))
/**
* The slice of PlatformPackager that getPublishConfigs actually reads. The
@@ -27,20 +33,29 @@ const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.j
*/
function fakePackager(metadata) {
const info = {
get repositoryInfo() {
return getRepositoryInfo(desktopDir, metadata, null)
},
appInfo: { version: '0.0.0', channel: null, updaterCacheDirName: 'hermes' },
config: {},
options: {}
}
return {
platformSpecificBuildOptions: {},
platformOptions: {}, // v27 API: getPublishConfigs reads platformOptions.publish
config: {},
info,
platform: { name: 'linux' },
appInfo: info.appInfo,
expandMacro: value => value
expandMacro: value => value,
// v27 API: getPublishConfigs awaits `packager.repositoryInfo` (top-level),
// not the v26 `packager.info.repositoryInfo` nesting. app-builder-lib@27's
// own getRepositoryInfo returns {} in this environment (hosted-git-info
// named-import interop), so parse metadata.repository directly.
get repositoryInfo() {
const repo = metadata && metadata.repository
if (!repo) return null
const parsed = hostedGitInfo.fromUrl(typeof repo === 'string' ? repo : repo.url)
if (!parsed) return null
return { user: parsed.user, project: parsed.project }
}
}
}
@@ -0,0 +1,129 @@
// Rewrite payload-internal python links to RELATIVE symlinks.
//
// uv venv --relocatable on POSIX makes venv/bin/python* a symlink (or a
// hardlink) onto the store interpreter, with an ABSOLUTE target (the build
// runner's staging path). An absolute symlink dangles once the unpacked
// tree moves (first-boot smoke, or a user installing to a different path),
// and a materialized COPY loses the tree identity the interpreter needs to
// resolve its stdlib (sys.prefix falls back to the baked build prefix →
// "No module named 'encodings'").
//
// The correct relocatable form is a RELATIVE symlink: the target lives
// inside the bundle (resources/agent-payload/tools/<entry>/bin/python3),
// so venv/bin/python -> ../tools/<entry>/bin/python3 survives moving the
// whole tree, and the interpreter resolves its real prefix through the
// link (stdlib found, no /install fallback).
//
// Only venv/bin is rewritten. Do not walk the rest of the payload: a
// file-symlink at Foo.framework/Foo is the framework binary, and
// flattening it makes codesign report "bundle format is ambiguous".
//
// A symlink whose target points OUTSIDE the bundle is a build bug (the
// payload's own venv must never reference the builder's machine) — fail
// loudly rather than ship a dangling link.
import fs from 'node:fs'
import path from 'node:path'
export function findPackedPayload(appOutDir, platform) {
if (!appOutDir) return null
const candidates =
platform === 'darwin'
? [
path.join(appOutDir, 'Contents', 'Resources', 'agent-payload'),
path.join(appOutDir, 'Hermes.app', 'Contents', 'Resources', 'agent-payload'),
path.join(appOutDir, 'HermesBundled.app', 'Contents', 'Resources', 'agent-payload')
]
: [path.join(appOutDir, 'resources', 'agent-payload')]
return candidates.find(p => fs.existsSync(p)) || null
}
// The store keeps verified downloads under fetch-<sha16>/. A sealed
// payload already has the unpacked entries. Apple's notary unpacks
// every archive it finds and rejects unsigned Mach-O inside them
// (uv tarball, leftover chromium). Drop the cache and leftover
// chromium store entries before codesign.
export function stripFetchCache(root) {
if (!root) return 0
const tools = path.join(root, 'tools')
if (!fs.existsSync(tools)) return 0
let removed = 0
for (const ent of fs.readdirSync(tools, { withFileTypes: true })) {
if (!ent.isDirectory()) continue
if (ent.name.startsWith('fetch-') || /^chromium(_headless_shell)?-\d+/.test(ent.name)) {
fs.rmSync(path.join(tools, ent.name), { recursive: true, force: true })
removed += 1
}
}
return removed
}
/**
* Rewrite payload venv/bin symlinks to RELATIVE targets that resolve
* inside the bundle. Returns the count rewritten.
*
* The single rule: a bundled venv's links must point at the payload's own
* store. Links whose target already resolves inside the payload are
* relativized (or left if already relative). Links whose target is the
* BUILD staging path (build/agent-payload/tools/<entry>/...) — which
* doesn't exist in the unpacked app — are rewritten to the matching
* store entry under <payload>/tools/. Anything else fails the build.
*/
export function relativizePayloadLinks(root) {
if (!root || !fs.existsSync(root)) return 0
const bin = path.join(root, 'venv', 'bin')
if (!fs.existsSync(bin)) return 0
const payloadRoot = path.resolve(root)
const toolsDir = path.join(payloadRoot, 'tools')
let count = 0
let entries
try {
entries = fs.readdirSync(bin, { withFileTypes: true })
} catch {
return 0
}
for (const ent of entries) {
const p = path.join(bin, ent.name)
let st
try {
st = fs.lstatSync(p)
} catch {
continue
}
if (!st.isSymbolicLink()) continue
let target
try {
target = fs.readlinkSync(p)
} catch {
continue
}
const absTarget = path.resolve(bin, target)
const insidePayload = absTarget !== payloadRoot && absTarget.startsWith(payloadRoot + path.sep)
let resolvedTarget = absTarget
if (!insidePayload) {
// The BUILD staging form: build/agent-payload/tools/<entry>/... .
// The tail names a store entry that must exist inside THIS payload.
const m = target.match(/(?:^|\/)tools\/(.+)$/)
if (!m) {
throw new Error(
`venv/bin/${ent.name} -> ${target} points outside the payload and does not name ` +
'a store entry (tools/<entry>/...); a bundled venv must reference the payload store',
)
}
const inPayload = path.join(toolsDir, m[1])
if (!fs.existsSync(inPayload)) {
throw new Error(
`venv/bin/${ent.name} -> ${target}: store entry ${m[1]} is not present in the payload ` +
`(${inPayload}); a bundled venv must reference the payload store`,
)
}
resolvedTarget = inPayload
}
const rel = path.relative(bin, resolvedTarget)
if (target === rel) continue
fs.unlinkSync(p)
fs.symlinkSync(rel, p)
count += 1
}
return count
}
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { findPackedPayload, relativizePayloadLinks, stripFetchCache } from './materialize-payload-links.mjs'
function tempRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-relativize-'))
}
test('findPackedPayload locates the mac nested payload', () => {
const root = tempRoot()
try {
const app = path.join(root, 'Hermes.app')
const payload = path.join(app, 'Contents', 'Resources', 'agent-payload')
fs.mkdirSync(payload, { recursive: true })
assert.equal(findPackedPayload(app, 'darwin'), payload)
assert.equal(findPackedPayload(root, 'darwin'), payload)
assert.equal(findPackedPayload(root, 'linux'), null)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks rewrites an absolute build-staging symlink to a payload-relative one', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const store = path.join(root, 'tools', 'python', 'bin')
const venv = path.join(root, 'venv', 'bin')
fs.mkdirSync(store, { recursive: true })
fs.mkdirSync(venv, { recursive: true })
const real = path.join(store, 'python3.11')
fs.writeFileSync(real, 'interpreter-bytes')
const link = path.join(venv, 'python3')
// The absolute build-staging form uv --relocatable writes: points at
// build/agent-payload/tools/... which does NOT exist here — but the
// store entry it names (tools/python/bin/python3.11) DOES exist in the
// payload root, which is what the rewrite keys on.
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', link)
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
const n = relativizePayloadLinks(root)
assert.equal(n, 1)
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
const target = fs.readlinkSync(link)
assert.equal(target.startsWith(path.sep), false) // now relative
assert.equal(path.resolve(venv, target), real)
assert.equal(fs.readFileSync(link, 'utf8'), 'interpreter-bytes') // resolves
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks throws when the named store entry is missing from the payload', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const venv = path.join(root, 'venv', 'bin')
fs.mkdirSync(venv, { recursive: true })
// Names tools/python/... but no such entry exists under the payload.
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', path.join(venv, 'python3'))
assert.throws(() => relativizePayloadLinks(root), /store entry .* is not present in the payload/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks leaves a sibling link (python3 -> python) alone', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const store = path.join(root, 'tools', 'python', 'bin')
const venv = path.join(root, 'venv', 'bin')
fs.mkdirSync(store, { recursive: true })
fs.mkdirSync(venv, { recursive: true })
const real = path.join(store, 'python3.11')
fs.writeFileSync(real, 'interpreter-bytes')
// python -> store link; python3 -> python sibling.
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', path.join(venv, 'python'))
fs.symlinkSync('python', path.join(venv, 'python3'))
const n = relativizePayloadLinks(root)
assert.equal(n, 1) // only the store link rewritten
assert.equal(fs.readlinkSync(path.join(venv, 'python3')), 'python') // sibling untouched
assert.equal(fs.readFileSync(path.join(venv, 'python3'), 'utf8'), 'interpreter-bytes') // resolves via chain
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks throws when the target does not name a store entry', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const venv = path.join(root, 'venv', 'bin')
fs.mkdirSync(venv, { recursive: true })
fs.symlinkSync('/usr/bin/python3.11', path.join(venv, 'python3'))
assert.throws(() => relativizePayloadLinks(root), /does not name a store entry/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks leaves already-relative links alone', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const store = path.join(root, 'tools', 'python', 'bin')
const venv = path.join(root, 'venv', 'bin')
fs.mkdirSync(store, { recursive: true })
fs.mkdirSync(venv, { recursive: true })
const real = path.join(store, 'python3.11')
fs.writeFileSync(real, 'interpreter-bytes')
const link = path.join(venv, 'python3')
fs.symlinkSync(path.relative(venv, real), link)
assert.equal(relativizePayloadLinks(root), 0)
assert.equal(fs.readlinkSync(link), path.relative(venv, real))
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('stripFetchCache removes only fetch- dirs under tools/', () => {
const root = tempRoot()
try {
const tools = path.join(root, 'tools')
fs.mkdirSync(path.join(tools, 'fetch-546f7f8a6c70ff13'), { recursive: true })
fs.writeFileSync(path.join(tools, 'fetch-546f7f8a6c70ff13', 'uv.tar.gz'), 'x')
fs.mkdirSync(path.join(tools, 'uv-0.12.3-darwin-arm64'), { recursive: true })
fs.writeFileSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv'), 'bin')
assert.equal(stripFetchCache(root), 1)
assert.equal(fs.existsSync(path.join(tools, 'fetch-546f7f8a6c70ff13')), false)
assert.equal(fs.existsSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv')), true)
assert.equal(stripFetchCache(root), 0)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('stripFetchCache also drops leftover chromium store entries', () => {
const root = tempRoot()
try {
const tools = path.join(root, 'tools')
fs.mkdirSync(path.join(tools, 'chromium-1208'), { recursive: true })
fs.writeFileSync(path.join(tools, 'chromium-1208', 'INSTALLATION_COMPLETE'), '')
fs.mkdirSync(path.join(tools, 'chromium_headless_shell-1208'), { recursive: true })
fs.mkdirSync(path.join(tools, 'uv-0.12.3-darwin-arm64'), { recursive: true })
fs.writeFileSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv'), 'bin')
assert.equal(stripFetchCache(root), 2)
assert.equal(fs.existsSync(path.join(tools, 'chromium-1208')), false)
assert.equal(fs.existsSync(path.join(tools, 'chromium_headless_shell-1208')), false)
assert.equal(fs.existsSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv')), true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('relativizePayloadLinks does not touch a framework file-symlink (not under venv/bin)', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const versioned = path.join(root, 'tools', 'chromium-1208', 'F.framework', 'Versions', 'A')
fs.mkdirSync(versioned, { recursive: true })
const real = path.join(versioned, 'F')
fs.writeFileSync(real, 'machO')
const link = path.join(root, 'tools', 'chromium-1208', 'F.framework', 'F')
fs.symlinkSync(path.join('Versions', 'A', 'F'), link)
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
assert.equal(relativizePayloadLinks(root), 0)
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
+99
View File
@@ -0,0 +1,99 @@
// msix-shared — the 4-part MSIX version derivation (minutes-since-stable
// for nightlies). The git-backed lookup is deterministic here because
// node:child_process.execFileSync is mocked; the math it feeds is the
// contract App Installer compares.
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { beforeEach, test, vi } from 'vitest'
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}))
// Re-import AFTER the mock so the module binds the mocked execFileSync.
const { execFileSync } = await import('node:child_process')
const msix = await import('../../../scripts/msix-shared.mjs')
// A fake desktop dir with just enough for appIdentity: product-identity.cjs
// (the bundled variant) + a package.json version.
function makeFakeDesktop(version) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msix-ident-'))
fs.writeFileSync(
path.join(dir, 'product-identity.cjs'),
"module.exports = { store: false, light: false, displayName: 'Hermes', appId: 'com.nousresearch.hermes-bundled', channel: 'latest', appNamePascal: 'HermesBundled', msixAppIdWithOrg: 'NousResearch.HermesBundled' }\n"
)
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'hermes-desktop', version }))
return dir
}
function gitMock(tags, stableEpoch) {
execFileSync.mockImplementation((cmd, args) => {
if (args[0] === 'tag') return `${tags.join('\n')}\n`
if (args[0] === 'log') return `${stableEpoch}\n`
throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`)
})
}
beforeEach(() => {
execFileSync.mockReset()
})
test('stable tag keeps the 3-part version plus .0', () => {
const desktop = makeFakeDesktop('0.27.1')
const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.1')
assert.equal(version, '0.27.1.0')
assert.equal(fileVersion, '0.27.1')
})
test('nightly version is tag base + minutes since the same-minor stable', () => {
const desktop = makeFakeDesktop('0.27.1')
// Stable v0.27.1 committed 2026-08-01T00:00:00Z; nightly cut 2026-08-29T01:02:03Z.
const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000)
gitMock(['v0.27.1', 'v0.27.2-nightly.20260829010203'], stableEpoch)
const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.2-nightly.20260829010203')
const expectedMinutes = Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000)
assert.equal(version, `0.27.2.${expectedMinutes}`)
// The artifact FILENAME carries the full nightly string (appInfo.version),
// not the 4-part feed version.
assert.equal(fileVersion, '0.27.2-nightly.20260829010203')
})
test('nightlyBuildMinutesFor is pure minutes math', () => {
const base = Date.UTC(2026, 7, 1) / 1000
const minutes = msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260829010203', base)
assert.equal(minutes, Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000))
})
test('nightlyBuildMinutesFor returns null for a stable tag', () => {
assert.equal(msix.nightlyBuildMinutesFor('v0.27.1', 0), null)
})
test('nightlyBuildMinutesFor rejects a stable base older than 45 days', () => {
const base = Date.UTC(2026, 5, 1) / 1000 // 2026-06-01
assert.throws(
() => msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260829010203', base),
/45 days|16-bit/i
)
})
test('legacy 8-digit nightly stamp still computes minutes (midnight of that day)', () => {
const base = Date.UTC(2026, 7, 1) / 1000
const minutes = msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260801', base)
assert.equal(minutes, 0)
})
test('buildAppInstaller pins the derived 4-part version everywhere', () => {
const xml = msix.buildAppInstaller({
baseUrl: 'https://updates.example.com',
variantChannelPath: 'win32/nightly',
identityName: 'NousResearch.HermesBundled',
version: '0.27.2.1234',
bundleFilename: 'HermesBundled-0.27.2.1234-win.msixbundle'
})
assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/nightly\/HermesBundled-0\.27\.2\.1234-win\.msixbundle"/)
const versionCount = (xml.match(/Version="0\.27\.2\.1234"/g) || []).length
assert.equal(versionCount, 2)
})
+50 -42
View File
@@ -1,55 +1,63 @@
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
// Wraps the electron-builder CLI so the arguments compose in one place, in
// the first spawn with no shell in between.
//
// electron-builder downloads and extracts Electron itself (via electronVersion
// + ELECTRON_MIRROR). Passing the local Electron dist makes v27 copy framework
// links as regular files. Its archive extraction preserves those links.
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
const entry = require.resolve('electron-builder')
let dir = path.dirname(entry)
while (!fs.existsSync(path.join(dir, 'package.json'))) {
const parent = path.dirname(dir)
if (parent === dir) {
throw new Error('electron-builder package root not found')
}
dir = parent
}
const bin = require(path.join(dir, 'package.json')).bin
const rel = typeof bin === 'string' ? bin : bin['electron-builder']
return path.join(dir, rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
const args = [...process.argv.slice(2)]
// package.json has no "build" field. Name the config file or electron-builder
// would look for one and silently use defaults.
if (!args.some(a => a === '--config' || a.startsWith('--config='))) {
args.push('--config', 'electron-builder.config.cjs')
}
// Never let electron-builder publish. On a CI tag build it auto-detects
// GitHub and demands GH_TOKEN after the artifacts are already built.
// The release workflow uploads artifacts in its own step.
if (!args.includes('--publish') && !args.some(a => a.startsWith('-p'))) {
args.push('--publish', 'never')
}
if (args.includes('--win') && process.env.AZURE_SIGN_ENDPOINT && process.env.AZURE_CLIENT_ID) {
console.log(
`[run-electron-builder] Windows signing: Azure Trusted Signing at ${process.env.AZURE_SIGN_ENDPOINT}`
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
// Cap concurrent fs.open calls in the electron-builder process.
// @electron/osx-sign walks the whole .app with Promise.all and no
// concurrency bound. The payload (lark_oapi alone is thousands of files)
// exhausts the macOS table: EMFILE on a random .py. Raising ulimit only
// moves the ceiling. --require, not an import: isbinaryfile captures
// promisify(fs.open) at its own load.
const preload = path.join(import.meta.dirname, 'fs-open-limit.cjs')
const result = spawnSync(process.execPath, ['--require', preload, electronBuilderCli(), ...args], {
stdio: 'inherit'
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
@@ -0,0 +1,212 @@
/**
* sanitize-pe-signatures.mjs — repair PE certificate tables that point past
* the end of their own file, before anything tries to sign the tree.
*
* Why this exists: python-build-standalone runs `llvm-strip` over every DLL
* in its Windows distributions. Stripping drops the trailing Authenticode
* blob but leaves IMAGE_DIRECTORY_ENTRY_SECURITY in the optional header
* still naming its old offset and size. The result is a PE whose certificate
* table begins at (or past) EOF.
*
* signtool refuses such a file with 0x800700C1 (ERROR_BAD_EXE_FORMAT), and
* AppxSIP inspects every PE inside an MSIX, so ONE of them fails the whole
* package signing — which is how a payload DLL nobody imports took down both
* Windows bundled release jobs. Upstream exempted the MSVC redists from
* stripping (python-build-standalone#856) but not tcl/tk, and the same class
* can arrive from any stripped third-party dist we bundle.
*
* The repair is to zero the dangling directory entry: eight bytes that say
* "there is no certificate here", which is already true. The bytes the entry
* pointed at are gone; nothing else in the file references them. A signature
* that is actually present is never touched — a valid entry is left exactly
* as it is, so this can never strip a real signature.
*/
import fs from "node:fs"
import path from "node:path"
import { isMain } from "./utils.mjs"
const PE_SIGNATURE = 0x00004550
const COFF_HEADER_SIZE = 24 // PE signature (4) + COFF file header (20)
const PE32_MAGIC = 0x10b
const PE32PLUS_MAGIC = 0x20b
const SECURITY_DIRECTORY_INDEX = 4
const DATA_DIRECTORY_ENTRY_SIZE = 8
/**
* Offset of NumberOfRvaAndSizes within the optional header. PE32 ends its
* fixed fields at 92. PE32+ drops BaseOfData (-4) and widens four fields
* from 4 to 8 bytes (+16), so its value sits 12 bytes later, at 108.
*
* @param {number} magic optional header magic (0x10b PE32, 0x20b PE32+)
* @returns {number | null} byte offset, or null when the magic is unknown
*/
export function numberOfRvaAndSizesOffset(magic) {
if (magic === PE32PLUS_MAGIC) return 108
if (magic === PE32_MAGIC) return 92
return null
}
/**
* Byte offset of the security data directory entry, relative to the start
* of the file.
*
* @param {number} peHeaderOffset value of e_lfanew (offset of "PE\0\0")
* @param {number} magic optional header magic
* @returns {number | null} absolute file offset, or null for an unknown magic
*/
export function securityDirectoryOffset(peHeaderOffset, magic) {
const rvaCount = numberOfRvaAndSizesOffset(magic)
if (rvaCount === null) return null
return (
peHeaderOffset +
COFF_HEADER_SIZE +
rvaCount +
4 + // NumberOfRvaAndSizes itself
SECURITY_DIRECTORY_INDEX * DATA_DIRECTORY_ENTRY_SIZE
)
}
/**
* Decide what a security directory entry describes. For this directory
* alone the "RVA" field is a plain file offset, not a virtual address, so
* it is directly comparable against the file size.
*
* "dangling" is the repairable case and is deliberately narrow: the entry
* must name a region that cannot exist in this file. Anything that fits
* inside the file is a real signature and is reported as "present" so the
* caller leaves it alone.
*
* @param {{ certOffset: number, certSize: number, fileSize: number }} entry
* @returns {"absent" | "present" | "dangling"}
*/
export function classifySecurityDirectory({ certOffset, certSize, fileSize }) {
if (certOffset === 0 && certSize === 0) return "absent"
// A zero offset with a nonzero size (or the reverse) names no usable
// region either; both halves must be sane for a signature to be readable.
if (certOffset === 0 || certSize === 0) return "dangling"
if (certOffset >= fileSize) return "dangling"
if (certOffset + certSize > fileSize) return "dangling"
return "present"
}
/**
* Read the security directory of a PE file.
*
* @param {string} file
* @returns {{ offsetInFile: number, certOffset: number, certSize: number, fileSize: number } | null}
* null when the file is not a PE with data directories at all.
*/
export function readSecurityDirectory(file) {
const fd = fs.openSync(file, "r")
try {
const fileSize = fs.fstatSync(fd).size
if (fileSize < 0x40) return null
const mz = Buffer.alloc(0x40)
fs.readSync(fd, mz, 0, 0x40, 0)
if (mz[0] !== 0x4d || mz[1] !== 0x5a) return null
const peHeaderOffset = mz.readUInt32LE(0x3c)
if (peHeaderOffset <= 0 || peHeaderOffset + COFF_HEADER_SIZE > fileSize) return null
const coff = Buffer.alloc(COFF_HEADER_SIZE)
fs.readSync(fd, coff, 0, COFF_HEADER_SIZE, peHeaderOffset)
if (coff.readUInt32LE(0) !== PE_SIGNATURE) return null
const optionalHeaderSize = coff.readUInt16LE(20)
if (optionalHeaderSize === 0) return null // object file: no data directories
const magicBuf = Buffer.alloc(2)
fs.readSync(fd, magicBuf, 0, 2, peHeaderOffset + COFF_HEADER_SIZE)
const offsetInFile = securityDirectoryOffset(peHeaderOffset, magicBuf.readUInt16LE(0))
if (offsetInFile === null) return null
if (offsetInFile + DATA_DIRECTORY_ENTRY_SIZE > peHeaderOffset + COFF_HEADER_SIZE + optionalHeaderSize) {
return null // header declares fewer directories than the security slot
}
const entry = Buffer.alloc(DATA_DIRECTORY_ENTRY_SIZE)
fs.readSync(fd, entry, 0, DATA_DIRECTORY_ENTRY_SIZE, offsetInFile)
return {
offsetInFile,
certOffset: entry.readUInt32LE(0),
certSize: entry.readUInt32LE(4),
fileSize,
}
} finally {
fs.closeSync(fd)
}
}
/**
* Zero the eight bytes of a security directory entry in place.
*
* @param {string} file
* @param {number} offsetInFile offset of the entry, from readSecurityDirectory
* @returns {void}
*/
export function clearSecurityDirectory(file, offsetInFile) {
const fd = fs.openSync(file, "r+")
try {
fs.writeSync(fd, Buffer.alloc(DATA_DIRECTORY_ENTRY_SIZE), 0, DATA_DIRECTORY_ENTRY_SIZE, offsetInFile)
} finally {
fs.closeSync(fd)
}
}
/**
* @param {string} dir
* @returns {Generator<string>}
*/
function* walkFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) yield* walkFiles(full)
else if (entry.isFile()) yield full
}
}
/**
* Repair every dangling security directory under a tree.
*
* @param {string} rootDir
* @returns {{ scanned: number, repaired: string[] }} repaired paths are
* relative to rootDir, in walk order.
*/
export function sanitizeTree(rootDir) {
const repaired = []
let scanned = 0
for (const file of walkFiles(rootDir)) {
let entry
try {
entry = readSecurityDirectory(file)
} catch {
continue // unreadable: packaging would already have failed on it
}
if (!entry) continue
scanned += 1
if (classifySecurityDirectory(entry) !== "dangling") continue
clearSecurityDirectory(file, entry.offsetInFile)
repaired.push(path.relative(rootDir, file))
}
return { scanned, repaired }
}
function main() {
const root = process.argv[2]
if (!root) {
console.error("usage: sanitize-pe-signatures.mjs <dir>")
process.exit(2)
}
const { scanned, repaired } = sanitizeTree(root)
console.log(`[sanitize-pe-signatures] ${scanned} PEs scanned, ${repaired.length} repaired`)
for (const file of repaired) {
console.log(` cleared dangling certificate table: ${file}`)
}
}
if (isMain(import.meta.url)) {
main()
}
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, test } from 'vitest'
import {
classifySecurityDirectory,
clearSecurityDirectory,
numberOfRvaAndSizesOffset,
readSecurityDirectory,
sanitizeTree,
} from '../scripts/sanitize-pe-signatures.mjs'
// ─── fixture builder: a PE with a chosen security directory ────────────────
const PE_HEADER_OFFSET = 0x80
const OPTIONAL_HEADER_SIZE = 240 // enough for 16 data directories in either width
/**
* Build the smallest byte sequence that parses as a PE with data
* directories, then append `trailing` bytes to stand in for a signature.
* certOffset/certSize are written verbatim so a test can describe a valid
* table, a dangling one, or none at all.
*/
function peFile({ magic = 0x20b, certOffset = 0, certSize = 0, trailing = 0 } = {}) {
const headerEnd = PE_HEADER_OFFSET + 24 + OPTIONAL_HEADER_SIZE
const buf = Buffer.alloc(headerEnd + trailing)
buf.write('MZ', 0, 'latin1')
buf.writeUInt32LE(PE_HEADER_OFFSET, 0x3c)
buf.writeUInt32LE(0x00004550, PE_HEADER_OFFSET) // "PE\0\0"
buf.writeUInt16LE(magic === 0x20b ? 0xaa64 : 0x014c, PE_HEADER_OFFSET + 4) // machine
buf.writeUInt16LE(OPTIONAL_HEADER_SIZE, PE_HEADER_OFFSET + 20)
buf.writeUInt16LE(magic, PE_HEADER_OFFSET + 24)
const rvaCount = numberOfRvaAndSizesOffset(magic)
buf.writeUInt32LE(16, PE_HEADER_OFFSET + 24 + rvaCount)
const secOff = PE_HEADER_OFFSET + 24 + rvaCount + 4 + 4 * 8
buf.writeUInt32LE(certOffset, secOff)
buf.writeUInt32LE(certSize, secOff + 4)
return { buf, securityDirectoryOffset: secOff, headerEnd }
}
let dir
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-sanitize-'))
})
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true })
})
/** @returns {string} absolute path of the written fixture */
function write(name, spec) {
const file = path.join(dir, name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, peFile(spec).buf)
return file
}
// ─── header arithmetic ──────────────────────────────────────────────────────
test('NumberOfRvaAndSizes sits at 92 for PE32 and 108 for PE32+', () => {
// The whole repair keys off this offset: read it wrong and a valid
// signature looks dangling (or the reverse).
assert.equal(numberOfRvaAndSizesOffset(0x10b), 92)
assert.equal(numberOfRvaAndSizesOffset(0x20b), 108)
assert.equal(numberOfRvaAndSizesOffset(0x107), null)
})
// ─── classification ─────────────────────────────────────────────────────────
test('a table wholly inside the file is a real signature', () => {
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 24, fileSize: 200 }), 'present')
// Exactly reaching EOF is the normal shape: signatures live at the end.
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 100, fileSize: 200 }), 'present')
})
test('a table that cannot fit in the file is dangling', () => {
// The stripped-DLL shape: offset equals the (now truncated) file size.
assert.equal(classifySecurityDirectory({ certOffset: 200, certSize: 24, fileSize: 200 }), 'dangling')
assert.equal(classifySecurityDirectory({ certOffset: 190, certSize: 24, fileSize: 200 }), 'dangling')
// Half-populated entries name no readable region either.
assert.equal(classifySecurityDirectory({ certOffset: 0, certSize: 24, fileSize: 200 }), 'dangling')
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 0, fileSize: 200 }), 'dangling')
})
test('an all-zero table means no signature and needs no repair', () => {
assert.equal(classifySecurityDirectory({ certOffset: 0, certSize: 0, fileSize: 200 }), 'absent')
})
// ─── reading and clearing ───────────────────────────────────────────────────
test('readSecurityDirectory locates the entry in both PE widths', () => {
for (const magic of [0x10b, 0x20b]) {
const spec = { magic, certOffset: 1234, certSize: 16 }
const file = write(`w${magic}.dll`, spec)
const entry = readSecurityDirectory(file)
assert.equal(entry.certOffset, 1234)
assert.equal(entry.certSize, 16)
assert.equal(entry.offsetInFile, peFile(spec).securityDirectoryOffset)
}
})
test('readSecurityDirectory ignores files that are not PEs with directories', () => {
const plain = path.join(dir, 'notes.txt')
fs.writeFileSync(plain, 'hello')
assert.equal(readSecurityDirectory(plain), null)
// MZ with no PE signature: a DOS-era stub, not a PE.
const stub = path.join(dir, 'stub.exe')
const buf = Buffer.alloc(0x100)
buf.write('MZ', 0, 'latin1')
buf.writeUInt32LE(0x80, 0x3c)
fs.writeFileSync(stub, buf)
assert.equal(readSecurityDirectory(stub), null)
})
test('clearSecurityDirectory zeroes the entry and changes nothing else', () => {
const spec = { magic: 0x20b, certOffset: 4096, certSize: 40 }
const file = write('cleared.dll', spec)
const before = fs.readFileSync(file)
clearSecurityDirectory(file, peFile(spec).securityDirectoryOffset)
const after = fs.readFileSync(file)
assert.equal(after.length, before.length)
const entry = readSecurityDirectory(file)
assert.equal(entry.certOffset, 0)
assert.equal(entry.certSize, 0)
// Byte-for-byte identical everywhere except the eight-byte entry.
const off = entry.offsetInFile
assert.deepEqual(after.subarray(0, off), before.subarray(0, off))
assert.deepEqual(after.subarray(off + 8), before.subarray(off + 8))
})
// ─── the tree pass ──────────────────────────────────────────────────────────
test('sanitizeTree repairs dangling tables and leaves real signatures alone', () => {
// The payload shape that broke the Windows bundled release: a stripped
// DLL whose table starts exactly at EOF, beside correctly signed files.
const stripped = write('python/DLLs/tcl86t.dll', { certOffset: 464, certSize: 12056 })
const signed = write('python/python.exe', { certOffset: 400, certSize: 64, trailing: 200 })
const unsigned = write('python/DLLs/_socket.pyd', {})
fs.writeFileSync(path.join(dir, 'python', 'LICENSE.txt'), 'not a PE')
const { scanned, repaired } = sanitizeTree(dir)
assert.equal(scanned, 3) // the three PEs; the text file is skipped
assert.deepEqual(repaired, [path.join('python', 'DLLs', 'tcl86t.dll')])
assert.equal(readSecurityDirectory(stripped).certSize, 0)
assert.equal(readSecurityDirectory(signed).certSize, 64) // untouched
assert.equal(readSecurityDirectory(unsigned).certSize, 0)
})
test('sanitizeTree is idempotent', () => {
write('DLLs/tk86t.dll', { certOffset: 464, certSize: 12064 })
assert.equal(sanitizeTree(dir).repaired.length, 1)
assert.deepEqual(sanitizeTree(dir).repaired, [])
})
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import { azureConfigFromEnv, shouldSignFile } from './sign-msix.mjs'
const require = createRequire(import.meta.url)
test('shouldSignFile admits only .msix and .msixbundle artifacts', () => {
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msix'), true)
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-x64.msix'), true)
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msixbundle'), true)
// Case-insensitive — artifactName could emit .MSIX on some host.
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.MSIX'), true)
})
test('shouldSignFile rejects the Store-submission variant (Partner Center re-signs)', () => {
// The Store- msix manifest Publisher is the Partner Center publisher ID
// (CN=EE6D86E4-...), which no signable cert subject can match — ATS
// cannot customize CN and CA/B requires the legal entity name — so
// SignerSign would fail 0x8007000B. Partner Center signs on ingestion.
assert.equal(
shouldSignFile('release/Store-HermesBundled-0.28.0-nightly.20260828211829-win-x64.msix'),
false
)
assert.equal(
shouldSignFile('release/Store-HermesBundled-0.28.0-nightly.20260828211829-win-arm64.msixbundle'),
false
)
// The out-of-store artifacts keep the only signature Windows validates.
assert.equal(
shouldSignFile('release/HermesBundled-0.28.0-nightly.20260828211829-win-x64.msix'),
true
)
})
test('shouldSignFile rejects every non-package file the hook is asked to sign', () => {
// The app exe and any payload binary are covered by the package's block
// map — signing them is wasted round-trips and would break the hash if
// done after makeappx packs the package.
assert.equal(shouldSignFile('release/win-unpacked/Hermes.exe'), false)
assert.equal(shouldSignFile('C:/work/hermes-agent/release/win-unpacked/Hermes.exe'), false)
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.nsis.exe'), false)
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msixupload'), false)
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.dll'), false)
assert.equal(shouldSignFile(''), false)
})
test('azureConfigFromEnv composes the Azure signing config from the environment', () => {
assert.deepEqual(
azureConfigFromEnv({
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
AZURE_SIGN_ACCOUNT: 'codesign2',
AZURE_SIGN_PROFILE: 'hermesagent',
AZURE_SIGN_PUBLISHER: 'CN=Nous Research Inc.'
}),
{
type: 'azure',
endpoint: 'https://cus.codesigning.azure.net',
codeSigningAccountName: 'codesign2',
certificateProfileName: 'hermesagent',
publisherName: 'CN=Nous Research Inc.'
}
)
// Missing vars stay undefined — the manager's ctor handles that.
assert.deepEqual(azureConfigFromEnv({}), {
type: 'azure',
endpoint: undefined,
codeSigningAccountName: undefined,
certificateProfileName: undefined,
publisherName: undefined
})
})
// ─── builder-bump tripwire ──────────────────────────────────────────────────
// sign-msix.mjs deep-imports app-builder-lib's WindowsSignAzureManager by
// file path (the package exports map exposes only "." and "./internal", so
// the dist path is reached the same way run-electron-builder.mjs finds the
// CLI: resolve the entry, walk up to the package root, then direct-file
// import). If an electron-builder/app-builder-lib bump moves or renames the
// class, THIS test is what fails in js-tests — before a release build does.
// 60s timeout: the direct-file import drags in app-builder-lib's whole
// module graph (toolsets, electronGet, vm), which takes ~30s on a cold
// disk cache — far past vitest's 5s default.
test('the real WindowsSignAzureManager resolves and constructs against a packager shim', { timeout: 60_000 }, async () => {
const entry = require.resolve('app-builder-lib')
let root = path.dirname(entry)
while (!fs.existsSync(path.join(root, 'package.json'))) {
const parent = path.dirname(root)
assert.notEqual(parent, root, 'app-builder-lib package root not found')
root = parent
}
const mod = await import(
pathToFileURL(path.join(root, 'dist', 'codeSign', 'win', 'windowsSignAzureManager.js')).href
)
assert.equal(typeof mod.WindowsSignAzureManager, 'function')
// Constructor contract (verified against 27.0.0-alpha.6): reads
// packager.platformOptions.sign (throws unless type === 'azure'),
// packager.config.toolsets (optional), and packager.buildResourcesDir
// (only stored — WineVmManager's constructor touches no filesystem).
const mgr = new mod.WindowsSignAzureManager({
platformOptions: {
sign: {
type: 'azure',
endpoint: 'x',
codeSigningAccountName: 'y',
certificateProfileName: 'z',
publisherName: 'p'
}
},
config: {},
buildResourcesDir: '/tmp'
})
assert.equal(typeof mgr.signFile, 'function')
assert.equal(typeof mgr.initialize, 'function')
})
@@ -0,0 +1,225 @@
// Sign payload Chromium with our Developer ID before osx-sign seals
// Hermes.app. Google's leftover signatures have no timestamp and no
// hardened runtime, so Apple's notary rejects them.
//
// Do not codesign a file that lives inside a .framework: codesign then
// treats Versions/A/Foo as a bundle and dies with "code object is not
// signed at all" (or "bundle format is ambiguous" on the Foo.framework/Foo
// symlink). Sign the enclosing .app with --deep. Loose Mach-O outside
// any .app (headless-shell + its dylibs) is signed as a file.
//
// signIgnore still keeps osx-sign off the chromium trees.
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const MACHO_MAGICS = new Set([
0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca
])
export function isMachO(file) {
const buf = Buffer.alloc(4)
let fd
try {
fd = fs.openSync(file, 'r')
if (fs.readSync(fd, buf, 0, 4, 0) !== 4) return false
} catch {
return false
} finally {
if (fd != null) fs.closeSync(fd)
}
return MACHO_MAGICS.has(buf.readUInt32BE(0))
}
export function chromiumRoots(payload) {
const tools = path.join(payload, 'tools')
if (!fs.existsSync(tools)) return []
return fs
.readdirSync(tools, { withFileTypes: true })
.filter(ent => ent.isDirectory() && /^chromium(_headless_shell)?-\d+/.test(ent.name))
.map(ent => path.join(tools, ent.name))
}
export function listTopLevelApps(root) {
const apps = []
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory() && p.endsWith('.app')) {
apps.push(p)
continue
}
if (ent.isDirectory()) walk(p)
}
}
walk(root)
return apps
}
export function listLooseMachO(root) {
const out = []
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory()) {
if (p.endsWith('.app')) continue
walk(p)
continue
}
if (!ent.isFile()) continue
if (isMachO(p)) out.push(p)
}
}
walk(root)
return out
}
// If Foo.framework/Foo is a regular file and Versions/*/Foo exists,
// replace the top-level copy with a symlink. Same for Versions/Current.
export function repairFrameworkLinks(root) {
let repaired = 0
const walk = dir => {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isSymbolicLink()) continue
if (ent.isDirectory() && ent.name.endsWith('.framework')) {
repaired += repairOneFramework(p)
continue
}
if (ent.isDirectory()) walk(p)
}
}
walk(root)
return repaired
}
function repairOneFramework(frameworkDir) {
let repaired = 0
const versions = path.join(frameworkDir, 'Versions')
if (!fs.existsSync(versions)) return 0
const versionDirs = fs
.readdirSync(versions, { withFileTypes: true })
.filter(ent => ent.isDirectory() && ent.name !== 'Current')
.map(ent => ent.name)
if (versionDirs.length === 0) return 0
const preferred = versionDirs.includes('A') ? 'A' : versionDirs[0]
const current = path.join(versions, 'Current')
if (!fs.existsSync(current) || !fs.lstatSync(current).isSymbolicLink()) {
if (fs.existsSync(current)) fs.rmSync(current, { recursive: true, force: true })
fs.symlinkSync(preferred, current)
repaired += 1
}
const stem = path.basename(frameworkDir, '.framework')
for (const name of [stem, 'Resources', 'Libraries', 'Helpers']) {
const top = path.join(frameworkDir, name)
const target = path.posix.join('Versions', 'Current', name)
const versioned = path.join(frameworkDir, 'Versions', preferred, name)
if (!fs.existsSync(versioned) && name !== stem) continue
if (fs.existsSync(top) && fs.lstatSync(top).isSymbolicLink()) continue
if (fs.existsSync(top)) fs.rmSync(top, { recursive: true, force: true })
if (fs.existsSync(versioned) || name === stem) {
fs.symlinkSync(target, top)
repaired += 1
}
}
return repaired
}
export function parseDeveloperId(identityList) {
const line = String(identityList || '')
.split(/\r?\n/)
.find(l => l.includes('Developer ID Application:'))
if (!line) return null
const quoted = line.match(/"([^"]+)"/)
return quoted ? quoted[1] : null
}
export function findDeveloperId(exec = execFileSync, keychain = null) {
if (process.env.CSC_NAME) return process.env.CSC_NAME
const args = ['find-identity', '-v', '-p', 'codesigning']
if (keychain) args.push(keychain)
let out
try {
out = exec('security', args, { encoding: 'utf8' })
} catch {
return null
}
return parseDeveloperId(out)
}
export async function resolveSigningIdentity(packager, exec = execFileSync) {
if (!packager?.codeSigningInfo?.value) {
return { identity: findDeveloperId(exec), keychain: null }
}
let info
try {
info = await packager.codeSigningInfo.value
} catch {
return { identity: findDeveloperId(exec), keychain: null }
}
const keychain = info?.keychainFile || process.env.CSC_KEYCHAIN || null
return { identity: findDeveloperId(exec, keychain), keychain }
}
function codesign(exec, identity, entitlements, keychain, target, deep) {
const args = [
'--force',
'--sign',
identity,
'--timestamp',
'--options',
'runtime',
'--entitlements',
entitlements
]
if (deep) args.push('--deep')
if (keychain) args.push('--keychain', keychain)
args.push(target)
exec('codesign', args, { stdio: 'pipe' })
}
export function signNestedChromium(payload, opts = {}) {
const identity = opts.identity
if (!identity) return { signed: 0, repaired: 0, identity: null }
const entitlements = opts.entitlements
if (!entitlements || !fs.existsSync(entitlements)) {
throw new Error(`sign-nested-chromium: entitlements missing at ${entitlements}`)
}
const exec = opts.exec ?? execFileSync
const keychain = opts.keychain || null
let signed = 0
let repaired = 0
for (const root of chromiumRoots(payload)) {
repaired += repairFrameworkLinks(root)
for (const app of listTopLevelApps(root)) {
codesign(exec, identity, entitlements, keychain, app, true)
signed += 1
}
for (const file of listLooseMachO(root)) {
codesign(exec, identity, entitlements, keychain, file, false)
signed += 1
}
}
return { signed, repaired, identity }
}
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
chromiumRoots,
isMachO,
listLooseMachO,
listTopLevelApps,
parseDeveloperId,
repairFrameworkLinks,
resolveSigningIdentity,
signNestedChromium
} from './sign-nested-chromium.mjs'
function tempRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-sign-chrome-'))
}
function machoBuf() {
const buf = Buffer.alloc(16)
buf.writeUInt32BE(0xfeedfacf, 0)
return buf
}
test('isMachO accepts a 64-bit Mach-O magic and rejects text', () => {
const dir = tempRoot()
try {
const bin = path.join(dir, 'a')
const txt = path.join(dir, 'b.txt')
fs.writeFileSync(bin, machoBuf())
fs.writeFileSync(txt, 'not a binary')
assert.equal(isMachO(bin), true)
assert.equal(isMachO(txt), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('chromiumRoots only names chromium store entries', () => {
const payload = tempRoot()
try {
fs.mkdirSync(path.join(payload, 'tools', 'chromium-1208'), { recursive: true })
fs.mkdirSync(path.join(payload, 'tools', 'chromium_headless_shell-1208'), { recursive: true })
fs.mkdirSync(path.join(payload, 'tools', 'uv-0.12.3-darwin-arm64'), { recursive: true })
const roots = chromiumRoots(payload).map(p => path.basename(p)).sort()
assert.deepEqual(roots, ['chromium-1208', 'chromium_headless_shell-1208'])
} finally {
fs.rmSync(payload, { recursive: true, force: true })
}
})
test('listTopLevelApps finds .app dirs and listLooseMachO skips them', () => {
const root = tempRoot()
try {
const app = path.join(root, 'Google Chrome for Testing.app')
fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true })
fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf())
const loose = path.join(root, 'chrome-headless-shell')
fs.writeFileSync(loose, machoBuf())
assert.deepEqual(listTopLevelApps(root), [app])
assert.deepEqual(listLooseMachO(root), [loose])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('repairFrameworkLinks turns a flattened Foo.framework/Foo into a symlink', () => {
if (process.platform === 'win32') return
const root = tempRoot()
try {
const fw = path.join(root, 'F.framework')
const versioned = path.join(fw, 'Versions', 'A')
fs.mkdirSync(path.join(versioned, 'Resources'), { recursive: true })
fs.writeFileSync(path.join(versioned, 'F'), machoBuf())
fs.writeFileSync(path.join(versioned, 'Resources', 'Info.plist'), '<plist/>')
fs.writeFileSync(path.join(fw, 'F'), machoBuf())
fs.mkdirSync(path.join(fw, 'Resources'))
fs.writeFileSync(path.join(fw, 'Resources', 'Info.plist'), '<plist/>')
const n = repairFrameworkLinks(root)
assert.ok(n >= 2)
assert.equal(fs.lstatSync(path.join(fw, 'F')).isSymbolicLink(), true)
assert.equal(fs.lstatSync(path.join(fw, 'Versions', 'Current')).isSymbolicLink(), true)
assert.equal(fs.readFileSync(path.join(fw, 'F')).equals(machoBuf()), true)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('parseDeveloperId takes the first Developer ID Application line', () => {
const listing = `
1) ABC "Apple Development: someone@example.com (TEAM)"
2) DEF "Developer ID Application: Nous Research Inc (763K57MW7Z)"
3) GHI "Developer ID Installer: Nous Research Inc (763K57MW7Z)"
`
assert.equal(parseDeveloperId(listing), 'Developer ID Application: Nous Research Inc (763K57MW7Z)')
assert.equal(parseDeveloperId('nothing here'), null)
})
test('signNestedChromium no-ops without an identity', () => {
const payload = tempRoot()
try {
const r = signNestedChromium(payload, { identity: null, entitlements: path.join(payload, 'missing.plist') })
assert.deepEqual(r, { signed: 0, repaired: 0, identity: null })
} finally {
fs.rmSync(payload, { recursive: true, force: true })
}
})
test('signNestedChromium --deep signs the .app and file-signs loose Mach-O', () => {
const payload = tempRoot()
try {
const entitlements = path.join(payload, 'entitlements.plist')
fs.writeFileSync(entitlements, '<plist/>')
const app = path.join(payload, 'tools', 'chromium-1208', 'Google Chrome for Testing.app')
fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true })
fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf())
const looseDir = path.join(payload, 'tools', 'chromium_headless_shell-1208')
fs.mkdirSync(looseDir, { recursive: true })
const loose = path.join(looseDir, 'chrome-headless-shell')
fs.writeFileSync(loose, machoBuf())
const calls = []
const r = signNestedChromium(payload, {
identity: 'Developer ID Application: Test',
entitlements,
exec: (cmd, args) => {
calls.push([cmd, ...args])
}
})
assert.equal(r.signed, 2)
assert.equal(calls.length, 2)
const appCall = calls.find(c => c.includes(app))
const fileCall = calls.find(c => c.includes(loose))
assert.ok(appCall.includes('--deep'))
assert.ok(!fileCall.includes('--deep'))
} finally {
fs.rmSync(payload, { recursive: true, force: true })
}
})
test('resolveSigningIdentity reads the packager keychain', async () => {
const seen = []
const packager = {
codeSigningInfo: {
value: Promise.resolve({ keychainFile: '/tmp/builder.keychain' })
}
}
const listing = ' 1) DEF "Developer ID Application: Nous Research Inc (763K57MW7Z)"\n'
const r = await resolveSigningIdentity(packager, (_cmd, args) => {
seen.push(args)
return listing
})
assert.equal(r.identity, 'Developer ID Application: Nous Research Inc (763K57MW7Z)')
assert.equal(r.keychain, '/tmp/builder.keychain')
assert.ok(seen[0].includes('/tmp/builder.keychain'))
})
+57 -72
View File
@@ -13,10 +13,8 @@ const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const PLATFORM = process.platform
// Platform-specific packaged-app layout. The thin installer ships an Electron
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
// no longer bundles the Hermes Agent Python payload (that's fetched at first
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
// Platform-specific packaged-app layout. The bundled app ships an Electron
// shell and the PM payload under resources/agent-payload.
const APP = (() => {
if (PLATFORM === 'darwin') {
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
@@ -49,17 +47,6 @@ const APP = (() => {
}
})()
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
const DEFAULT_HERMES_HOME = (() => {
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
return path.join(process.env.LOCALAPPDATA, 'hermes')
}
return path.join(os.homedir(), '.hermes')
})()
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
function die(message) {
@@ -144,12 +131,11 @@ function resolveDmgPath() {
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
function resolveNsisPath() {
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
function resolveMsixPath() {
if (!exists(RELEASE_ROOT)) return null
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
.filter(name => /\.msix$/i.test(name) && /win/i.test(name))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
@@ -160,7 +146,7 @@ function resolveNsisPath() {
function ensureDmg() {
if (PLATFORM !== 'darwin') {
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
die('DMG mode is macOS-only; on Windows use the `msix` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
return
@@ -168,14 +154,14 @@ function ensureDmg() {
run('npm', ['run', 'dist:mac:dmg'])
}
function ensureNsis() {
function ensureMsix() {
if (PLATFORM !== 'win32') {
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
die('MSIX mode is win32-only; on macOS use the `dmg` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveMsixPath()) {
return
}
run('npm', ['run', 'dist:win:nsis'])
run('npm', ['run', 'dist:win:msix'])
}
function openApp() {
@@ -278,49 +264,50 @@ function launchFresh() {
console.log(` HERMES_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
}
// Validate the packaged bundle matches the thin-installer architecture:
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
// launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
// with package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar).
// The packaged app must contain the PM payload, node-pty, and renderer assets.
function validateBundle() {
if (!exists(APP.binary)) {
die(`Missing packaged app binary: ${APP.binary}`)
}
// Negative assertion: the OLD fat-installer factory payload must NOT be
// present anymore. If a stray ship of hermes_cli sneaks back in we want
// to fail loudly rather than re-introduce the 400MB delta we just removed.
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
if (exists(staleFactoryMarker)) {
die(
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
// The payload may be the real pm bundle (staged by build-bundled-desktop /
// `hermes pm bundle --out build/agent-payload`) or the external stub
// (plain `npm run pack` in the PR/JS lane — the app fetches the runtime at
// first launch via the stage protocol). Validate the payload only when a
// real one is present; the stub is the thin-installer contract.
const payloadRoot = path.join(APP.resourcesPath, 'agent-payload')
const payloadManifestPath = path.join(payloadRoot, 'manifest.json')
let payloadManifest = null
if (exists(payloadManifestPath)) {
try {
payloadManifest = JSON.parse(fs.readFileSync(payloadManifestPath, 'utf8'))
} catch (err) {
die(`Bundled payload manifest is not valid JSON: ${err.message}`)
}
}
if (payloadManifest != null && payloadManifest.external !== true) {
for (const key of ['repo', 'store', 'venv']) {
if (typeof payloadManifest[key] !== 'string') {
die(`Bundled payload manifest is missing ${key}: ${JSON.stringify(payloadManifest)}`)
}
}
const payloadPython = path.join(
payloadRoot,
payloadManifest.venv,
PLATFORM === 'win32' ? 'Scripts' : 'bin',
PLATFORM === 'win32' ? 'python.exe' : 'python'
)
}
// Positive assertion: install-stamp.json carries a sane commit + branch
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
if (!exists(stampPath)) {
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
}
let stamp
try {
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
} catch (err) {
die(`install-stamp.json is not valid JSON: ${err.message}`)
}
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
}
if (!stamp.branch || typeof stamp.branch !== 'string') {
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
if (!exists(payloadPython)) {
die(`Missing bundled payload Python: ${payloadPython}`)
}
if (PLATFORM === 'win32') {
const payloadShim = path.join(payloadRoot, payloadManifest.venv, 'Scripts', 'hermes.exe')
if (!exists(payloadShim)) {
die(`Missing bundled payload shim: ${payloadShim}`)
}
}
}
// Positive assertion: node-pty native deps shipped
@@ -360,7 +347,7 @@ function validateBundle() {
// Renderer payload check (either unpacked or in the asar)
if (exists(APP.unpackedDistIndex)) {
return { stamp, nodeBinaries }
return { payloadManifest, nodeBinaries }
}
if (!exists(APP.asarPath)) {
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
@@ -373,24 +360,22 @@ function validateBundle() {
if (!normalized.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
}
return { stamp, nodeBinaries }
return { payloadManifest, nodeBinaries }
}
function printArtifacts(options = {}) {
const runtimeRoot = options.runtimeRoot || VENV_ROOT
const stamp = options.stamp
const payloadManifest = options.payloadManifest
console.log('\nDesktop artifacts:')
console.log(` app: ${APP.appPath}`)
if (PLATFORM === 'darwin') {
console.log(` dmg: ${resolveDmgPath()}`)
} else if (PLATFORM === 'win32') {
const exe = resolveNsisPath()
if (exe) console.log(` installer: ${exe}`)
const msix = resolveMsixPath()
if (msix) console.log(` package: ${msix}`)
}
console.log(` runtime: ${runtimeRoot}`)
if (stamp) {
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
if (payloadManifest) {
console.log(` payload: ${payloadManifest.repo} + ${payloadManifest.venv}`)
}
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
@@ -402,8 +387,8 @@ function help() {
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
npm run test:desktop:dmg # (macOS only) build DMG and open it
npm run test:desktop:nsis # (win32 only) build NSIS installer
npm run test:desktop:all # build installer, validate app payload, print paths
npm run test:desktop:msix # (win32 only) build MSIX package
npm run test:desktop:all # build the platform package and validate the payload
Fast rerun (skip rebuild if the packaged app already exists):
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
@@ -425,14 +410,14 @@ if (MODE === 'existing') {
ensureDmg()
openDmg()
printArtifacts()
} else if (MODE === 'nsis') {
ensureNsis()
} else if (MODE === 'msix') {
ensureMsix()
printArtifacts(validateBundle())
} else if (MODE === 'all') {
if (PLATFORM === 'darwin') {
ensureDmg()
} else if (PLATFORM === 'win32') {
ensureNsis()
ensureMsix()
} else {
ensurePackagedApp()
}
@@ -0,0 +1,42 @@
// windows-file-version.mjs — the Windows VERSIONINFO quad for a release tag.
//
// Windows VERSIONINFO holds four 16-bit fields, so a nightly's semver
// string cannot go in it. resedit splits whatever it is handed on "." and
// clamps each token to [0, 65535], so `0.28.0-nightly.20260819171926`
// becomes 0.28.0.65535: "0-nightly" parses as NaN and falls to the min,
// and the timestamp saturates at the max. Every nightly of a minor then
// shows the same meaningless quad on the Details tab.
//
// The timestamp is the only part worth showing there — the semver line is
// identical across a whole nightly series — so pack it as
// yyyy.mmdd.hhmm.ss. Every field stays under 65536, and the quad compares
// in timestamp order, which is the order Windows sorts versions in.
//
// Display metadata only: electron-updater keys on the semver version in
// extraMetadata, never on this quad.
/**
* The `yyyy.mmdd.hhmm.ss` quad for a nightly tag, or null for a stable tag
* (whose version is already four legal fields or fewer, so app-builder-lib's
* own handling is correct).
*
* @param {string} releaseTag A release tag, with the leading `v`.
* @returns {string | null}
*/
export function windowsFileVersion(releaseTag) {
const stamp = /-nightly\.(20\d{6}(?:\d{6})?)$/.exec(String(releaseTag))?.[1]
if (!stamp) {
return null
}
// The legacy date-only shape (YYYYMMDD) is midnight of that day.
const parts = /^(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?$/.exec(stamp)
if (!parts) {
return null
}
const [, year, month, day, hour, minute, second] = parts
const field = (a, b) => Number(`${a ?? '00'}${b ?? '00'}`)
return `${Number(year)}.${field(month, day)}.${field(hour, minute)}.${Number(second ?? 0)}`
}
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { windowsFileVersion } from './windows-file-version.mjs'
test('stable tags leave VERSIONINFO to electron-builder', () => {
assert.equal(windowsFileVersion('v0.20.5'), null)
assert.equal(windowsFileVersion('v0.28.0'), null)
})
test('nightly tags pack the timestamp into a legal quad', () => {
assert.equal(windowsFileVersion('v0.28.0-nightly.20260819171926'), '2026.819.1719.26')
assert.equal(windowsFileVersion('v0.20.5-nightly.20260826'), '2026.826.0.0')
})
+5
View File
@@ -18,6 +18,7 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { ConfirmHost } from '@/components/confirm-host'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { ExternalOpenFailedDialog } from '@/components/external-open-failed-dialog'
import { FindBar } from '@/components/find-bar'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
@@ -1245,6 +1246,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
(error card action); renders nothing until requested. */}
<SendDiagnosticsHost />
{/* Fallback modal when opening an external URL fails — carries the URL
so a dead system-browser click is never silent. */}
<ExternalOpenFailedDialog />
{/* Petdex floating mascot — renders nothing unless installed + enabled.
Never in the HUD: that window is the chat bar and nothing else. */}
{!isHudWindow() && !isBrowserWindow() && <FloatingPet />}
+27 -183
View File
@@ -1,209 +1,53 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
$updateApply,
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow,
refreshDesktopVersion,
startActiveUpdate
} from '@/store/updates'
import { UpdateStatusCard, VersionHero } from '@/components/update-status'
import { VersionDetails } from '@/components/version-details'
import { useI18n } from '@/i18n'
import { RefreshCw } from '@/lib/icons'
import { $connection } from '@/store/session'
import { $desktopVersion, checkBackendUpdates, refreshDesktopVersion } from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/'
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
if (!ms) {
return a.never
}
const diff = Date.now() - ms
if (diff < 60_000) {
return a.justNow
}
if (diff < 3_600_000) {
return a.minAgo(Math.round(diff / 60_000))
}
if (diff < 86_400_000) {
return a.hoursAgo(Math.round(diff / 3_600_000))
}
return a.daysAgo(Math.round(diff / 86_400_000))
}
export function AboutSettings() {
const { t } = useI18n()
const a = t.settings.about
const u = t.updates
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
const checking = useStore($updateChecking)
const [justChecked, setJustChecked] = useState(false)
const connection = useStore($connection)
const remote = connection?.mode === 'remote'
// The version atom is loaded once at app boot, which makes About show a
// stale number after a self-update (the running binary is current, the
// displayed string is not). Re-read on mount so opening About always
// reflects the running build.
// reflects the running build. In remote mode also seed the backend update
// state so the backend card opens answered instead of on "never checked".
useEffect(() => {
void refreshDesktopVersion()
}, [])
const behind = status?.behind ?? 0
// behind is null when the exact count is unknowable (shallow clone): the
// backend flags that case via updateAvailable instead of a number.
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
const handleCheck = async () => {
setJustChecked(false)
const next = await checkUpdates()
setJustChecked(Boolean(next))
}
let statusLine: string
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? a.cantUpdate
statusTone = 'error'
} else if (status?.error) {
statusLine = a.cantReach
statusTone = 'error'
} else if (applying) {
statusLine = a.installing
statusTone = 'available'
} else if (updateAvailable) {
statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown
statusTone = 'available'
} else if (status) {
statusLine = a.onLatest
} else {
statusLine = a.tapCheck
}
if (remote) {
void checkBackendUpdates()
}
}, [remote])
return (
<SettingsContent>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<BrandMark className="size-16" />
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
</p>
</div>
{version?.bundleOutOfSync && (
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0">
<p className="font-medium">{a.bundleOutOfSync}</p>
<p className="mt-1 text-xs text-muted-foreground">{a.bundleOutOfSyncDesc}</p>
<Button asChild className="mt-2" size="sm" variant="textStrong">
<a
href={INSTALLER_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.bundleOutOfSyncAction}
</a>
</Button>
</div>
</div>
</div>
)}
</div>
<VersionHero version={version} />
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
<SectionHeading icon={RefreshCw} title={u.updatesSection} />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
{updateAvailable && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
</Button>
</>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
<div className="grid gap-3">
<UpdateStatusCard target="client" />
{/* The desktop client and a remote backend update independently — in
remote mode the statusbar shows both pills, so About shows both
states too. The backend has no GitHub release notes link of its
own; the client card already carries it. */}
{remote && <UpdateStatusCard showReleaseNotes={false} target="backend" />}
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
{version && <VersionDetails version={version} />}
<UninstallSection />
</div>
@@ -348,7 +348,9 @@ export function ConnectionsRegistrySection() {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
message: result?.error
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
: t.boot.failure.signInIncompleteMessage
})
}
} catch (err) {
@@ -622,7 +622,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
message: result?.error
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
: t.boot.failure.signInIncompleteMessage
})
}
} catch (err) {
+83 -34
View File
@@ -14,7 +14,16 @@ import {
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { Progress } from '@/components/ui/progress'
import type { DesktopUpdateBlocker, DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { UpdateStatusCard, VersionHero } from '@/components/update-status'
import { VersionDetails } from '@/components/version-details'
import type {
DesktopUpdateBlocker,
DesktopUpdateCommit,
DesktopUpdateStage,
DesktopUpdateStatus,
DesktopVersionInfo,
UpdaterMechanismClient
} from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons'
@@ -24,6 +33,7 @@ import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateChecking,
$updateOverlayOpen,
@@ -52,6 +62,7 @@ export function UpdatesOverlay() {
const backendStatus = useStore($backendUpdateStatus)
const backendChecking = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const isBackend = target === 'backend'
const status = isBackend ? backendStatus : clientStatus
@@ -111,7 +122,9 @@ export function UpdatesOverlay() {
onOpenAutoFocus={preventCloseButtonAutoFocus}
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
{phase === 'applying' && (
<ApplyingView apply={apply} isBackend={isBackend} statusMechanism={status?.mechanism} />
)}
{phase === 'manual' && (
<ManualView command={apply.command ?? null} message={apply.message} onDone={() => handleClose(false)} />
@@ -142,6 +155,7 @@ export function UpdatesOverlay() {
status={status}
target={target}
updateAvailable={updateAvailable}
version={desktopVersion}
/>
)}
</DialogContent>
@@ -158,7 +172,8 @@ function IdleView({
onRetryCheck,
status,
target,
updateAvailable
updateAvailable,
version
}: {
behind: number
checking: boolean
@@ -169,6 +184,7 @@ function IdleView({
status: DesktopUpdateStatus | null
target: UpdateTarget
updateAvailable: boolean
version: DesktopVersionInfo | null
}) {
const { t } = useI18n()
const u = t.updates
@@ -196,38 +212,50 @@ function IdleView({
)
}
if (!status.supported) {
const details = version ? <VersionDetails version={version} /> : null
// App-installer check-unknown (OS checker unavailable): NOT "no updates"
// and NOT a generic error — the OS also installs updates automatically on
// restart (it re-reads the .appinstaller feed on every launch), so the
// honest view names that path. Only this mechanism gets it.
if (status.mechanism === 'app-installer' && status.error && !status.updateAvailable) {
return (
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
<div className="grid gap-4 px-6 pb-6 pt-1 pr-8">
<VersionHero
renderHeading={heading => (
<DialogTitle className="text-lg font-semibold tracking-tight">{heading}</DialogTitle>
)}
version={version}
/>
<div className="flex flex-col items-center gap-2 text-center">
<AlertCircle className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">{u.checkUnknownTitleAppInstaller}</p>
<p className="max-w-prose text-sm leading-5 text-muted-foreground">{u.checkUnknownBodyAppInstaller}</p>
</div>
<Button onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
{details}
</div>
)
}
if (status.error) {
// Everything that is NOT the install pitch — unsupported, check error, and
// already-latest — is exactly the About page's state: render the shared
// hero + status card so the two surfaces cannot drift. The card owns the
// check/retry actions (its "Check now" covers the old Try-again button).
if (!status.supported || status.error || !updateAvailable) {
return (
<CenteredStatus
action={
<Button disabled={checking} onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
}
body={u.connectionRetry}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
}
if (!updateAvailable) {
return (
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
icon={<BrandMark className="size-12" />}
title={u.allSetTitle}
/>
<div className="grid gap-4 px-6 pb-6 pt-1 pr-8">
<VersionHero
renderHeading={heading => (
<DialogTitle className="text-lg font-semibold tracking-tight">{heading}</DialogTitle>
)}
version={version}
/>
<UpdateStatusCard target={target} />
{details}
</div>
)
}
@@ -238,8 +266,16 @@ function IdleView({
// Name what's being updated. In remote mode the overlay acts on the connected
// backend, not the local client — say so. When there are no commit rows to
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
// instead of generic filler.
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
// instead of generic filler. On a release-feed channel (stable), name the
// release tag instead of commit vocabulary.
const { title, body } = resolveUpdateCopy({
target,
shownItems,
channel: status?.channel === 'stable' ? 'stable' : 'main',
latestTag: status?.latestTag ?? null,
mechanism: status?.mechanism,
copy: u
})
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
@@ -382,11 +418,24 @@ function GuiSkewView({ message, onDone }: { message?: string; onDone: () => void
)
}
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
function ApplyingView({
apply,
isBackend,
statusMechanism
}: {
apply: UpdateApplyState
isBackend: boolean
statusMechanism?: UpdaterMechanismClient
}) {
const { t } = useI18n()
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
const isAppInstaller = statusMechanism === 'app-installer'
const body = isAppInstaller
? u.applyingBodyAppInstaller
: isBackend
? u.applyingBodyBackend
: u.applyingBody
const currentMessage = apply.message.trim()
const recentLog = apply.log.slice(-4)
@@ -29,7 +29,23 @@ function stubDesktop(config: Record<string, unknown>, overrides: Record<string,
const original = window.hermesDesktop
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config, ...overrides }
value: {
getRecentLogs: async () => ({ lines: [] }),
getConnectionConfig: async () => config,
getBootstrapState: async () => ({
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
bundled: false
}),
...overrides
}
})
return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original })
@@ -227,4 +243,42 @@ describe('BootFailureOverlay', () => {
restore()
}
})
it('swaps Repair for "Reinstall the app" on a bundled install', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
const restore = stubDesktop(
{ mode: 'local' },
{
getBootstrapState: async () => ({
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
bundled: true
}),
openExternal
}
)
try {
render(<BootFailureOverlay />)
// The bundled artifact has no installer to repair with — the action is
// a docs link, and the hint says reinstall instead of re-run installer.
expect(await screen.findByRole('button', { name: /reinstall the app/i })).toBeTruthy()
expect(screen.queryByRole('button', { name: /repair install/i })).toBeNull()
expect(screen.getByText(/reinstall the app to restore/i)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /reinstall the app/i }))
await waitFor(() => expect(openExternal).toHaveBeenCalledWith('https://hermes-agent.nousresearch.com/docs/user-guide/desktop'))
} finally {
restore()
}
})
})
@@ -7,6 +7,7 @@ import { Loader } from '@/components/ui/loader'
import { LogView } from '@/components/ui/log-view'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { DESKTOP_DOCS_URL } from '@/lib/docs'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
@@ -55,6 +56,10 @@ export function BootFailureOverlay() {
// A remote/cloud backend that failed to boot is fixable from gateway settings,
// so the escape hatch earns emphasis (local failures keep it as a quiet ghost).
const [remoteFailure, setRemoteFailure] = useState(false)
// A bundled install (payload ships in-app) has no installer to repair with —
// the only recovery is reinstalling the app. Read from the bootstrap state
// snapshot so the Repair affordance is replaced by "Reinstall the app".
const [bundled, setBundled] = useState(false)
// Swap the card body to the embedded Gateway settings panel in place of routing
// to the full Settings page (keeps the user on the recovery surface, no z-index
// juggling, no second connection form to maintain).
@@ -77,6 +82,31 @@ export function BootFailureOverlay() {
.catch(() => undefined)
}, [boot.error, visible])
// Bundled installs carry their runtime as an immutable payload — repair
// would re-run an installer that must never fire for them. Resolve the
// artifact kind from the bootstrap snapshot (set by main.ts on setup-choice
// broadcast) so the recovery actions swap Repair → "Reinstall the app".
useEffect(() => {
if (!visible) {
return
}
let cancelled = false
void window.hermesDesktop
?.getBootstrapState()
.then(snapshot => {
if (!cancelled && snapshot) {
setBundled(snapshot.bundled)
}
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [boot.error, visible])
// Resolve whether this boot failure is a remote-gateway reauth so we can
// offer the actionable "Sign in" path instead of the local-only recovery
// buttons. Runs whenever the overlay becomes visible.
@@ -221,7 +251,9 @@ export function BootFailureOverlay() {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
message: result?.error
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
: t.boot.failure.signInIncompleteMessage
})
} catch (err) {
notifyError(err, t.boot.failure.signInFailed)
@@ -329,19 +361,29 @@ export function BootFailureOverlay() {
} else {
// Local failure: Use-local is redundant with Retry (both re-target local), so
// it's dropped here; keep it for remote failures where it's the fall-back.
// On a bundled install the Repair affordance is replaced by "Reinstall the
// app" — the payload is immutable, so there is no installer to re-run.
actions = [
retryAction,
{
key: 'repair',
label: copy.repairInstall,
onClick: () => void repair(),
icon: <Wrench />,
variant: 'secondary',
busy: 'repair'
},
bundled
? {
key: 'reinstall',
label: copy.reinstallApp,
onClick: () => openExternalLink(DESKTOP_DOCS_URL),
icon: <ExternalLink />,
variant: 'secondary'
}
: {
key: 'repair',
label: copy.repairInstall,
onClick: () => void repair(),
icon: <Wrench />,
variant: 'secondary',
busy: 'repair'
},
{ ...settingsAction, variant: 'ghost' }
]
hint = copy.repairHint
hint = bundled ? copy.bundledReinstallHint : copy.repairHint
}
if (view === 'connect') {
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { localCardPresentation } from './desktop-install-local-card'
test('none is the installer offer with the install-to footer', () => {
assert.deepEqual(localCardPresentation('none'), {
title: 'installLocalTitle',
desc: 'installLocalDesc',
disabled: false,
showInstallTo: true
})
})
test('installed uses the existing-runtime copy and hides the install-to footer', () => {
assert.deepEqual(localCardPresentation('installed'), {
title: 'useLocalTitle',
desc: 'useLocalDesc',
disabled: false,
showInstallTo: false
})
})
test('bundled uses the bundled flavor of the existing-runtime copy', () => {
assert.deepEqual(localCardPresentation('bundled'), {
title: 'useLocalTitle',
desc: 'bundledLocalDesc',
disabled: false,
showInstallTo: false
})
})
test('bundled-damaged is disabled and never shows the install-to footer', () => {
assert.deepEqual(localCardPresentation('bundled-damaged'), {
title: 'bundledDamagedTitle',
desc: 'bundledDamagedDesc',
disabled: true,
showInstallTo: false
})
})
test('an absent local field falls back to the installer offer (old backends)', () => {
assert.equal(localCardPresentation(undefined).title, 'installLocalTitle')
assert.equal(localCardPresentation(undefined).disabled, false)
})
@@ -0,0 +1,69 @@
/**
* desktop-install-local-card.ts
*
* Pure presentation derivation for the setup screen's local card. The card
* has four states, keyed off the `local` field the backend stamps on every
* resolved backend:
*
* none — no Hermes on this machine: the card IS an install offer.
* installed — a runtime resolved (PATH hermes, active root, …): clicking
* starts the existing runtime; nothing downloads.
* bundled — a healthy bundled install: the payload is the runtime.
* bundled-damaged— a bundled install whose payload failed to resolve: the
* card is disabled (never an install action); reinstall the
* app instead.
*
* Extracted so the disabled/title/desc/footer logic is unit-testable without
* rendering the overlay.
*/
export type LocalCardState = 'none' | 'installed' | 'bundled' | 'bundled-damaged'
export interface LocalCardPresentation {
/** i18n key into `t.install` for the card title. */
title: 'installLocalTitle' | 'useLocalTitle' | 'bundledDamagedTitle'
/** i18n key into `t.install` for the card body. */
desc: 'installLocalDesc' | 'useLocalDesc' | 'bundledLocalDesc' | 'bundledDamagedDesc'
/** When true the card cannot be clicked — there is no install to fire. */
disabled: boolean
/** Whether the "Will install to <root>" footer is accurate for this state. */
showInstallTo: boolean
}
export function localCardPresentation(local: LocalCardState | undefined): LocalCardPresentation {
switch (local) {
case 'installed':
return {
title: 'useLocalTitle',
desc: 'useLocalDesc',
disabled: false,
showInstallTo: false
}
case 'bundled':
return {
title: 'useLocalTitle',
desc: 'bundledLocalDesc',
disabled: false,
showInstallTo: false
}
case 'bundled-damaged':
return {
title: 'bundledDamagedTitle',
desc: 'bundledDamagedDesc',
disabled: true,
showInstallTo: false
}
case 'none':
default:
return {
title: 'installLocalTitle',
desc: 'installLocalDesc',
disabled: false,
showInstallTo: true
}
}
}
@@ -17,6 +17,7 @@ function bootstrapState(overrides: Partial<DesktopBootstrapState> = {}): Desktop
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
bundled: false,
...overrides
}
}
@@ -93,7 +94,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('shows the remote/local choice without installer progress', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
})
)
@@ -102,6 +103,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Connect to existing Hermes')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
expect(screen.getByText(/Will install to/i)).toBeTruthy()
expect(screen.queryByText(/steps complete/i)).toBeNull()
expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull()
})
@@ -109,7 +111,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('continues local bootstrap only when Install Hermes locally is selected', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
})
)
@@ -131,7 +133,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
})
)
@@ -150,7 +152,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('keeps the local-start error when the first snapshot commits under the click', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
})
)
@@ -173,7 +175,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('clears a stale local-start error when a repair presents a different root', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
})
)
@@ -200,7 +202,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('opens the remote connection form from the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -216,7 +218,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('returns from the remote connection form to the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -234,7 +236,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('requires a successful token connection test before applying remote config', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -304,7 +306,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('ignores a completed probe after the gateway URL becomes invalid', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -348,7 +350,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('does not enable Apply when credentials change during a connection test', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -401,7 +403,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('restores remote apply controls when applying the tested connection fails', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -448,7 +450,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
it('signs in, tests, and applies a password-style remote gateway', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
})
)
@@ -574,3 +576,81 @@ describe('DesktopInstallOverlay first-run setup', () => {
expect(screen.queryByText('Hermes needs a one-time install')).toBeNull()
})
})
describe('DesktopInstallOverlay bundled / already-installed cards', () => {
it('shows the use-existing card for an installed runtime and hides the install-to footer', async () => {
installDesktopMock(
bootstrapState({
setupChoice: {
platform: 'win32',
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
local: 'installed',
bundled: false
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Use Hermes on this computer')).toBeTruthy()
expect(screen.getByText(/already installed here/i)).toBeTruthy()
expect(screen.queryByText(/Will install to/i)).toBeNull()
expect(screen.queryByText('Install Hermes locally')).toBeNull()
})
it('shows the bundled flavor for a healthy bundled install', async () => {
installDesktopMock(
bootstrapState({
setupChoice: {
platform: 'win32',
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
local: 'bundled',
bundled: true
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Use Hermes on this computer')).toBeTruthy()
expect(screen.getByText(/included with this app/i)).toBeTruthy()
expect(screen.queryByText(/Will install to/i)).toBeNull()
})
it('disables the local card for a damaged bundled payload — never an install action', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: {
platform: 'win32',
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
local: 'bundled-damaged',
bundled: true
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Use Hermes bundled with this app')).toBeTruthy()
expect(screen.getByText(/missing or damaged/i)).toBeTruthy()
const localCard = screen.getByText('Use Hermes bundled with this app').closest('button')
expect(localCard).toBeTruthy()
expect(localCard?.getAttribute('aria-disabled')).toBe('true')
// Clicking the disabled card must never fire the local bootstrap bridge.
fireEvent.click(screen.getByText('Use Hermes bundled with this app'))
expect(desktop.continueBootstrapLocal).not.toHaveBeenCalled()
// The docs "Reinstall the app" affordance is present and opens the docs
// site — not an installer.
const reinstall = screen.getByText('Reinstall the app')
expect(reinstall).toBeTruthy()
fireEvent.click(reinstall)
expect(desktop.openExternal).toHaveBeenCalledWith('https://hermes-agent.nousresearch.com/docs/user-guide/desktop')
// The remote card stays clickable — connecting remotely is the working path.
fireEvent.click(screen.getByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
})
})
@@ -15,10 +15,12 @@ import type {
DesktopBootstrapState
} from '@/global'
import { useI18n } from '@/i18n'
import { DESKTOP_DOCS_URL } from '@/lib/docs'
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { localCardPresentation } from './desktop-install-local-card'
import { FirstRunRemoteForm } from './first-run-remote-form'
/**
@@ -170,7 +172,8 @@ const EMPTY_STATE: DesktopBootstrapState = {
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
unsupportedPlatform: null,
bundled: false
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
@@ -188,10 +191,13 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
setupChoice: ev.active
? {
platform: ev.platform || state.setupChoice?.platform || 'unknown',
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || ''
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || '',
local: ev.local || state.setupChoice?.local || 'none',
bundled: Boolean(ev.bundled)
}
: null,
unsupportedPlatform: null
unsupportedPlatform: null,
bundled: Boolean(ev.bundled)
}
}
@@ -397,6 +403,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
if (state.setupChoice) {
// The local card's copy + behavior derive from what the backend found on
// this machine: 'none' is an install offer, the rest say "use what's
// already here" — and 'bundled-damaged' disables the card entirely
// (there is no install to fire; reinstall the app instead).
const localState = state.setupChoice.local
const localPres = localCardPresentation(localState)
return (
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
@@ -404,7 +417,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
<p className="mt-1.5 text-sm text-muted-foreground">
{localState === 'none' ? copy.setupChoiceDesc : copy.setupChoiceDescLocal}
</p>
</div>
</div>
@@ -422,8 +437,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</button>
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
disabled={localStarting}
aria-disabled={localPres.disabled}
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-not-allowed disabled:opacity-60"
disabled={localStarting || localPres.disabled}
onClick={async () => {
setLocalStart({ root: activeRoot, starting: true, error: null })
@@ -447,12 +463,25 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
) : (
<Monitor className="size-4 text-muted-foreground" />
)}
<span>{copy.installLocalTitle}</span>
<span>{copy[localPres.title]}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy[localPres.desc]}</p>
</button>
</div>
{localPres.disabled ? (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="size-4 shrink-0" />
<button
className="text-(--ui-text-secondary) underline underline-offset-2 hover:text-foreground"
onClick={() => window.hermesDesktop?.openExternal?.(DESKTOP_DOCS_URL)}
type="button"
>
{copy.reinstallApp}
</button>
</div>
) : null}
{localStartError ? (
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
@@ -460,10 +489,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
) : null}
<div className="mt-6 text-xs text-muted-foreground">
{copy.installTo}{' '}
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
</div>
{localPres.showInstallTo ? (
<div className="mt-6 text-xs text-muted-foreground">
{copy.installTo}{' '}
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
</div>
) : null}
</div>
</div>
)
@@ -0,0 +1,97 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ExternalOpenFailedDialog } from './external-open-failed-dialog'
const windowsMock = vi.hoisted(() => ({
isHudWindow: vi.fn(() => false),
isBrowserWindow: vi.fn(() => false)
}))
vi.mock('@/store/windows', () => windowsMock)
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
function installBridge() {
const onExternalOpenFailed = vi.fn()
const writeClipboard = vi.fn().mockResolvedValue(undefined)
desktopWindow.hermesDesktop = {
onExternalOpenFailed,
writeClipboard
} as unknown as Window['hermesDesktop']
return { onExternalOpenFailed, writeClipboard }
}
function fail(listener: unknown, url: string, message?: string) {
act(() => {
;(listener as (payload: { url: string; message?: string }) => void)({ url, message })
})
}
afterEach(() => {
windowsMock.isHudWindow.mockReturnValue(false)
windowsMock.isBrowserWindow.mockReturnValue(false)
vi.restoreAllMocks()
cleanup()
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('ExternalOpenFailedDialog', () => {
it('subscribes to open-failure events on mount', () => {
const { onExternalOpenFailed } = installBridge()
render(<ExternalOpenFailedDialog />)
expect(onExternalOpenFailed).toHaveBeenCalledTimes(1)
expect(typeof onExternalOpenFailed.mock.calls[0][0]).toBe('function')
})
it('shows the URL when an open-failure event fires', () => {
const { onExternalOpenFailed } = installBridge()
render(<ExternalOpenFailedDialog />)
const listener = onExternalOpenFailed.mock.calls[0][0]
expect(screen.queryByText('https://example.com/dead')).toBeNull()
fail(listener, 'https://example.com/dead')
expect(screen.getByText('https://example.com/dead')).not.toBeNull()
})
it('copies the URL when the copy action is used', async () => {
const { onExternalOpenFailed, writeClipboard } = installBridge()
render(<ExternalOpenFailedDialog />)
const listener = onExternalOpenFailed.mock.calls[0][0]
fail(listener, 'https://example.com/dead')
fireEvent.click(screen.getByRole('button', { name: /copy/i }))
await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com/dead'))
})
it('closes when dismissed', async () => {
const { onExternalOpenFailed } = installBridge()
render(<ExternalOpenFailedDialog />)
const listener = onExternalOpenFailed.mock.calls[0][0]
fail(listener, 'https://example.com/dead')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByText('https://example.com/dead')).toBeNull())
})
it('renders nothing in a HUD window', () => {
installBridge()
windowsMock.isHudWindow.mockReturnValue(true)
const view = render(<ExternalOpenFailedDialog />)
expect(view.container.firstChild).toBeNull()
})
})
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import type { ExternalOpenFailedPayload } from '@/global.d'
import { useI18n } from '@/i18n'
import { isBrowserWindow, isHudWindow } from '@/store/windows'
// Global fallback for a failed external URL open. main's openExternalUrl
// reports every `shell.openExternal` rejection over a dedicated IPC event
// (e.g. no https handler registered on Linux — a dead click otherwise). The
// dialog shows the URL so the user can copy it and open it manually.
export function ExternalOpenFailedDialog() {
const { t } = useI18n()
const [failure, setFailure] = useState<ExternalOpenFailedPayload | null>(null)
useEffect(() => {
if (!window.hermesDesktop?.onExternalOpenFailed) {return}
return window.hermesDesktop.onExternalOpenFailed(setFailure)
}, [])
if (isHudWindow() || isBrowserWindow()) {
return null
}
const copy = t.externalOpenFailed
return (
<Dialog onOpenChange={open => (!open ? setFailure(null) : undefined)} open={Boolean(failure)}>
<DialogContent className="max-w-[30rem]" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{copy.message}</DialogDescription>
</DialogHeader>
<div className="max-h-40 overflow-auto break-all rounded-md border bg-muted p-3 select-all">
{failure?.url}
</div>
<DialogFooter>
{failure ? <CopyButton text={failure.url}>{copy.copyUrl}</CopyButton> : null}
<Button onClick={() => setFailure(null)} variant="ghost">
{copy.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -138,7 +138,7 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
setOauthConnected(Boolean(result.connected))
if (!result.connected) {
setError(copy.signInIncomplete)
setError(result.error ? `${copy.signInIncomplete}: ${result.error}` : copy.signInIncomplete)
}
} catch (err) {
setError(errorMessage(err))
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
import { en } from '@/i18n/en'
import type { UpdateApplyState } from '@/store/updates'
import { deriveUpdateStatus } from './update-status'
const IDLE_APPLY: UpdateApplyState = {
applying: false,
stage: 'idle',
message: '',
percent: null,
error: null,
command: null,
log: []
}
function derive(status: DesktopUpdateStatus | null, apply: UpdateApplyState = IDLE_APPLY, checking = false) {
return deriveUpdateStatus({ apply, checking, status, target: 'client', u: en.updates })
}
describe('deriveUpdateStatus', () => {
it('never-checked: idle tone, invites a check', () => {
const view = derive(null)
expect(view.tone).toBe('idle')
expect(view.updateAvailable).toBe(false)
expect(view.line).toBe(en.updates.tapCheck)
})
it('unsupported wins over everything else and keeps the backend message', () => {
const view = derive({ supported: false, message: 'managed install', behind: 5, updateAvailable: true })
expect(view.tone).toBe('unsupported')
expect(view.line).toBe('managed install')
expect(view.supported).toBe(false)
})
it('check error surfaces the tone and the transport message separately', () => {
const view = derive({ supported: true, error: 'check-failed', message: 'ECONNREFUSED' })
expect(view.tone).toBe('error')
expect(view.line).toBe(en.updates.cantReach)
expect(view.error).toBe('ECONNREFUSED')
})
it('applying beats available so the card cannot offer a second install', () => {
const view = derive({ supported: true, behind: 3 }, { ...IDLE_APPLY, applying: true, stage: 'update' })
expect(view.applying).toBe(true)
expect(view.tone).toBe('available')
expect(view.line).toBe(en.updates.installing)
})
it('restart stage counts as applying even when the applying flag already dropped', () => {
const view = derive({ supported: true }, { ...IDLE_APPLY, applying: false, stage: 'restart' })
expect(view.applying).toBe(true)
})
it('behind count renders counted copy; count-free updateAvailable falls back', () => {
expect(derive({ supported: true, behind: 4 }).line).toBe(en.updates.updateReady(4))
// Shallow clone: exact count unknowable, flagged via updateAvailable.
const unknown = derive({ supported: true, behind: 0, updateAvailable: true })
expect(unknown.line).toBe(en.updates.updateReadyUnknown)
expect(unknown.updateAvailable).toBe(true)
})
it('up to date: idle tone with the latest-version line', () => {
const view = derive({ supported: true, behind: 0 })
expect(view.tone).toBe('idle')
expect(view.updateAvailable).toBe(false)
expect(view.line).toBe(en.updates.latestBody)
})
it('backend target says the backend is current, not "you"', () => {
const view = deriveUpdateStatus({
apply: IDLE_APPLY,
checking: false,
status: { supported: true, behind: 0 },
target: 'backend',
u: en.updates
})
expect(view.line).toBe(en.updates.latestBodyBackend)
})
})
@@ -0,0 +1,286 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { DesktopUpdateStatus, DesktopVersionInfo } from '@/global'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$updateApply,
$updateChecking,
$updateStatus,
checkBackendUpdates,
checkUpdates,
openUpdateOverlayFor,
startActiveUpdate,
type UpdateApplyState,
type UpdateTarget
} from '@/store/updates'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/'
export type UpdateStatusTone = 'idle' | 'available' | 'error' | 'unsupported'
export interface UpdateStatusView {
line: string
tone: UpdateStatusTone
error?: string
updateAvailable: boolean
applying: boolean
supported: boolean
}
/**
* One status derivation for every "am I up to date?" surface (About page,
* updates overlay). Pure so the tone/copy contract is unit-testable.
*/
export function deriveUpdateStatus({
apply,
checking,
status,
target,
u
}: {
apply: UpdateApplyState
checking: boolean
status: DesktopUpdateStatus | null
target: UpdateTarget
u: Translations['updates']
}): UpdateStatusView {
const behind = status?.behind ?? 0
// behind is null when the exact count is unknowable (shallow clone): the
// backend flags that case via updateAvailable instead of a number.
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
if (!supported) {
return { applying, line: status?.message ?? u.unsupportedMessage, supported, tone: 'unsupported', updateAvailable }
}
if (status?.error) {
return {
applying,
error: status.message,
line: u.cantReach,
supported,
tone: 'error',
updateAvailable
}
}
if (applying) {
return { applying, line: u.installing, supported, tone: 'available', updateAvailable }
}
if (updateAvailable) {
return {
applying,
line: behind > 0 ? u.updateReady(behind) : u.updateReadyUnknown,
supported,
tone: 'available',
updateAvailable
}
}
if (status) {
return {
applying,
line: target === 'backend' ? u.latestBodyBackend : u.latestBody,
supported,
tone: 'idle',
updateAvailable
}
}
return { applying, line: checking ? u.checking : u.tapCheck, supported, tone: 'idle', updateAvailable }
}
function relativeTime(ms: number | undefined, u: Translations['updates']): string {
if (!ms) {
return u.never
}
const diff = Date.now() - ms
if (diff < 60_000) {
return u.justNow
}
if (diff < 3_600_000) {
return u.minAgo(Math.round(diff / 60_000))
}
if (diff < 86_400_000) {
return u.hoursAgo(Math.round(diff / 3_600_000))
}
return u.daysAgo(Math.round(diff / 86_400_000))
}
/**
* The "Hermes Desktop / version / brand mark" hero shared by the About page
* and the updates overlay, including the bundle-out-of-sync warning. The
* heading render is injectable so a dialog surface can emit a DialogTitle
* for its accessible name while About keeps a plain h2.
*/
export function VersionHero({
renderHeading,
version
}: {
renderHeading?: (heading: string) => ReactNode
version: DesktopVersionInfo | null
}) {
const { t } = useI18n()
const u = t.updates
return (
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<BrandMark className="size-16" />
<div>
{renderHeading ? (
renderHeading(u.appName)
) : (
<h2 className="text-lg font-semibold tracking-tight">{u.appName}</h2>
)}
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? u.version(version.appVersion) : u.versionUnavailable}
</p>
</div>
{version?.bundleOutOfSync && (
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0">
<p className="font-medium">{u.bundleOutOfSync}</p>
<p className="mt-1 text-xs text-muted-foreground">{u.bundleOutOfSyncDesc}</p>
<Button asChild className="mt-2" size="sm" variant="textStrong">
<a
href={INSTALLER_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{u.bundleOutOfSyncAction}
</a>
</Button>
</div>
</div>
</div>
)}
</div>
)
}
/**
* The bordered update-state card (status line, last-checked age, check /
* update / release-notes actions) in the About page's visual language.
* Target-aware: 'client' reads the desktop self-update atoms, 'backend' the
* remote backend's. The actual apply flow stays in the updates overlay —
* "Update now" opens it and starts the install there.
*/
export function UpdateStatusCard({
showReleaseNotes = true,
target
}: {
showReleaseNotes?: boolean
target: UpdateTarget
}) {
const { t } = useI18n()
const u = t.updates
const isBackend = target === 'backend'
const status = useStore(isBackend ? $backendUpdateStatus : $updateStatus)
const checking = useStore(isBackend ? $backendUpdateChecking : $updateChecking)
const apply = useStore(isBackend ? $backendUpdateApply : $updateApply)
const [justChecked, setJustChecked] = useState(false)
const view = deriveUpdateStatus({ apply, checking, status, target, u })
const handleCheck = async (): Promise<void> => {
setJustChecked(false)
const next = await (isBackend ? checkBackendUpdates() : checkUpdates())
setJustChecked(Boolean(next))
}
return (
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
view.tone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
view.tone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
(view.tone === 'idle' || view.tone === 'unsupported') && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{view.tone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : view.tone === 'error' || view.tone === 'unsupported' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{view.line}</p>
{view.error && <p className="mt-1 text-xs text-muted-foreground">{view.error}</p>}
{view.tone !== 'unsupported' && (
<p className="mt-1 text-xs text-muted-foreground">
{u.lastChecked(relativeTime(status?.fetchedAt, u))}
{justChecked && !checking ? u.justNowSuffix : ''}
</p>
)}
</div>
</div>
{view.tone !== 'unsupported' && (
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || view.applying}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? u.checkingShort : u.checkNow}
</Button>
{view.updateAvailable && view.supported && !view.applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{u.updateNow}
</Button>
<Button onClick={() => openUpdateOverlayFor(target)} size="sm" variant="textStrong">
{u.seeWhatsNew}
</Button>
</>
)}
{showReleaseNotes && (
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{u.releaseNotes}
</a>
</Button>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,109 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { DesktopVersionInfo } from '@/global'
import { I18nProvider } from '@/i18n'
import { VersionDetails } from './version-details'
const baseVersion: DesktopVersionInfo = {
appVersion: '0.19.0',
electronVersion: '37.0.0',
hermesRoot: '/tmp/hermes',
nodeVersion: '22.0.0',
platform: 'linux'
}
afterEach(cleanup)
describe('VersionDetails', () => {
it('omits the branch suffix when no branch is present', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'ci', branch: null }} />
</I18nProvider>
)
expect(screen.getByText('Build Origin')).toBeTruthy()
expect(screen.getByText('CI')).toBeTruthy()
})
it('shows a literal branch named unknown inline', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'ci', branch: 'unknown' }} />
</I18nProvider>
)
expect(screen.getByText('CI (unknown)')).toBeTruthy()
})
it('shows the Nix source and distribution from the stamp', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'nix', distribution: 'nix' }} />
</I18nProvider>
)
expect(screen.getByText('Build Origin')).toBeTruthy()
expect(screen.getAllByText('Nix')).toHaveLength(2)
expect(screen.getByText('Distribution')).toBeTruthy()
})
it('distinguishes CI provenance from the Docker distribution', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'ci', distribution: 'docker' }} />
</I18nProvider>
)
expect(screen.getByText('CI')).toBeTruthy()
expect(screen.getByText('Distribution')).toBeTruthy()
expect(screen.getByText('Docker')).toBeTruthy()
})
it('shows the runtime row for an embedded build running its payload', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails
version={{
...baseVersion,
distribution: 'desktop-app',
hermesRuntime: { type: 'embedded' }
}}
/>
</I18nProvider>
)
expect(screen.getByText('Runtime')).toBeTruthy()
expect(screen.getByText('Embedded runtime')).toBeTruthy()
})
it('shows the runtime source with its location when an external build runs a machine runtime', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails
version={{
...baseVersion,
hermesRuntime: { type: 'external', source: { type: 'git', root: '/home/u/.hermes/hermes-agent' } }
}}
/>
</I18nProvider>
)
expect(screen.getByText('Runtime')).toBeTruthy()
expect(screen.getByText('git (/home/u/.hermes/hermes-agent)')).toBeTruthy()
expect(screen.queryByText('External (uses the machine runtime)')).toBeNull()
})
it('shows the generic external label before the first backend spawn', () => {
render(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, hermesRuntime: { type: 'external' } }} />
</I18nProvider>
)
expect(screen.getByText('Runtime')).toBeTruthy()
expect(screen.getByText('External (uses the machine runtime)')).toBeTruthy()
})
})
@@ -0,0 +1,101 @@
import type { DesktopVersionInfo, RuntimeSource } from '@/global'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
/**
* Human label for an external build's runtime source: the resolution rung
* plus the location it resolved from, when there is one.
*/
function runtimeSourceLabel(source: RuntimeSource): string {
const where = 'root' in source && source.root ? source.root : 'command' in source ? source.command : null
return where ? `${source.type} (${where})` : source.type
}
/**
* Shared build-provenance display. Reads from `$desktopVersion`
* (populated from the build stamp / `hermes:version` IPC), so every
* surface — the About settings page, the updates overlay — shows the
* same version, branch, commit, distribution, runtime, and install id
* from one source of truth.
*/
export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
const { t } = useI18n()
const u = t.updates
const source =
version.source === 'ci' ? 'CI' : version.source ? version.source[0].toUpperCase() + version.source.slice(1) : null
const distribution =
version.distribution === 'nix'
? 'Nix'
: version.distribution === 'docker'
? 'Docker'
: version.distribution === 'desktop-app'
? u.versionDetailsDistributionDesktop
: null
const runtime =
version.hermesRuntime?.type === 'embedded'
? u.versionDetailsRuntimeEmbedded
: version.hermesRuntime?.type === 'external' && version.hermesRuntime.source
? runtimeSourceLabel(version.hermesRuntime.source)
: version.hermesRuntime?.type === 'external'
? u.versionDetailsRuntimeExternal
: null
return (
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsVersion}</dt>
<dd>
v{version.appVersion}
{version.dirty && <span className="text-warning"> (!)</span>}
</dd>
</div>
{version.commit && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsCommit}</dt>
<dd className="break-all text-right">
<ExternalLink
className="break-all font-mono text-xs"
href={`https://github.com/NousResearch/hermes-agent/commit/${version.commit}`}
>
{version.commit.slice(0, 14)}
</ExternalLink>
{version.dirty && <span className="text-warning"> {u.versionDetailsUncommittedChanges}</span>}
</dd>
</div>
)}
{source && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsBuildOrigin}</dt>
<dd>
{source}
{version.branch && version.branch !== 'main' ? ` (${version.branch})` : ''}
</dd>
</div>
)}
{distribution && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsDistribution}</dt>
<dd>{distribution}</dd>
</div>
)}
{runtime && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsRuntime}</dt>
<dd className="break-all text-right">{runtime}</dd>
</div>
)}
{version.installId && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsInstallId}</dt>
<dd className="break-all text-right font-mono text-xs">
{version.installId} ({version.hermesRoot})
</dd>
</div>
)}
</dl>
)
}
+67
View File
@@ -299,6 +299,7 @@ declare global {
setDisableF12?: (blocked: boolean) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
onExternalOpenFailed?: (callback: (payload: ExternalOpenFailedPayload) => void) => () => void
openPreviewInBrowser?: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
/** A site's icon as a data URL, or '' when it has none we can read.
@@ -326,6 +327,8 @@ declare global {
message: string
componentStack: string
}) => void
/** Append one raw line to desktop.log (fire-and-forget, notifyError path). */
logLine?: (line: string) => void
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Reveal a path in the OS file manager (Finder / Explorer).
@@ -558,8 +561,44 @@ export interface DesktopVersionInfo {
bundleOutOfSync?: boolean
/** Commits under apps/desktop/ the running bundle is missing (null unknown). */
bundleCommitsBehind?: null | number
/** Build provenance from the install stamp (empty for packaged builds with
* no stamp / a bare `app.getVersion()` fallback). */
baseVersion?: string
branch?: string | null
commit?: string | null
distance?: number
dirty?: boolean
source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown'
distribution?: 'desktop-app' | 'docker' | 'nix'
/** sha16 of the canonical install-root path — the per-install channel key and
* the shape `hermes update --install-id` prints. */
installId?: string
/** What this build carries (embedded / light / external) and where an
* external backend resolved from. Bundled artifacts run their payload; light
* artifacts have no runtime and only reach remote backends. */
hermesRuntime?: { type: 'embedded' } | { type: 'light' } | { type: 'external'; source?: RuntimeSource }
}
/** Where an external build's backend came from. Mirrors the resolution ladder
* in `resolveHermesBackend()`: `git` / `source` / sealed stewards are the
* Python install methods from `installation.tree.install_method()`; the
* Electron-only rungs (`hermes-root`, `path`, `system-python`, `bootstrap`)
* are resolution facts the backend cannot see. Each variant carries the
* location it resolved from, when there is one. */
export type RuntimeSource =
| { type: 'hermes-root'; root: string } // HERMES_DESKTOP_HERMES_ROOT — explicit developer override
| { type: 'git'; root: string } // checkout at a managed install root, $HERMES_HOME/hermes-agent
| { type: 'source'; root: string } // a git checkout anywhere else
| { type: 'docker'; root: string | null } // sealed tree stewarded by Docker
| { type: 'nix'; root: string | null } // sealed tree stewarded by Nix
| { type: 'desktop-app'; root: string | null } // sealed tree stewarded by the desktop bundle
| { type: 'desktop-bootstrap'; root: string } // canonical install created by the desktop first-launch bootstrap
| { type: 'unknown' } // no stamp, no .git — provenance cannot be told
| { type: 'path'; command: string } // an existing `hermes` CLI found on PATH
| { type: 'system-python'; command: string } // pip-installed hermes_cli on system Python
| { type: 'bootstrap' } // nothing usable yet; the first-launch installer runs
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
export interface DesktopUninstallSummary {
@@ -591,8 +630,17 @@ export interface DesktopUpdateCommit {
at: number
}
export type UpdaterMechanismClient =
| 'app-installer'
| 'external'
| 'windows-handoff'
| 'posix-handoff'
| 'manual'
export interface DesktopUpdateStatus {
supported: boolean
/** Which mechanism owns updates for this install (see electron/updater). */
mechanism?: UpdaterMechanismClient
updateAvailable?: boolean
branch?: string
currentBranch?: string
@@ -606,6 +654,11 @@ export interface DesktopUpdateStatus {
currentSha?: string
/** Backend only: the version string the backend reports for itself. */
currentVersion?: string
/** Release feed the check read from ('stable'/'nightly'); when set, the
* update is a release and `latestTag` names it instead of a commit count. */
channel?: 'stable' | 'nightly'
/** The latest release tag on a release-feed channel, e.g. `v0.18.0`. */
latestTag?: string | null
targetSha?: string
commits?: DesktopUpdateCommit[]
dirty?: boolean
@@ -1015,10 +1068,16 @@ export interface DesktopConnectionProbeResult {
error: string | null
}
export interface ExternalOpenFailedPayload {
url: string
message?: string
}
export interface DesktopOauthLoginResult {
ok: boolean
baseUrl: string
connected: boolean
error?: string
}
export interface DesktopOauthLogoutResult {
@@ -1127,6 +1186,10 @@ export interface DesktopBootstrapUnsupportedPlatform {
export interface DesktopBootstrapSetupChoice {
platform: string
activeRoot: string
/** What the local card represents: 'none' = installer offer; the rest = use existing. */
local: 'none' | 'installed' | 'bundled' | 'bundled-damaged'
/** This artifact is a bundled install (payload ships in-app). */
bundled: boolean
}
export interface DesktopBootstrapState {
@@ -1139,6 +1202,8 @@ export interface DesktopBootstrapState {
completedAt: number | null
setupChoice: DesktopBootstrapSetupChoice | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
/** This artifact is a bundled install (payload ships in-app). */
bundled: boolean
}
export type DesktopBootstrapEvent =
@@ -1148,6 +1213,8 @@ export type DesktopBootstrapEvent =
active: boolean
platform?: string
activeRoot?: string
local?: DesktopBootstrapSetupChoice['local']
bundled?: boolean
}
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
+45 -32
View File
@@ -1,6 +1,13 @@
import { defineLocale } from './define-locale'
export const ar = defineLocale({
externalOpenFailed: {
title: 'تعذّر فتح هذا الرابط',
message: 'لا يوجد متصفح مسجل لفتح هذا العنوان. انسخ الرابط وافتحه يدويًا.',
copyUrl: 'نسخ الرابط',
close: 'إغلاق'
},
sendDiagnostics: {
title: 'إرسال التشخيصات إلى Nous',
privacyNotice:
@@ -663,38 +670,6 @@ export const ar = defineLocale({
'updates.nonInteractiveLocalChanges':
'عندما يحدّث Hermes نفسه من التطبيق دون موجه طرفية، احتفظ بتعديلات المصدر المحلية أو تجاهلها.'
},
about: {
heading: 'حول Hermes',
version: value => `الإصدار ${value}`,
versionUnavailable: 'الإصدار غير متاح',
bundleOutOfSync: 'إصدار التطبيق قديم',
bundleOutOfSyncDesc:
'تم تحديث وقت تشغيل Hermes، لكن تطبيق سطح المكتب نفسه لا يزال إصدارًا قديمًا — لن تظهر ميزات الواجهة الجديدة (مثل Bot Mode) حتى يتم تحديث التطبيق. شغّل التحديث أدناه لإعادة بناء التطبيق. إذا لم يختفِ هذا التحذير، فأعد التثبيت من أحدث مثبّت لسطح المكتب.',
bundleOutOfSyncAction: 'الحصول على المثبّت',
updates: 'التحديثات',
checkNow: 'التحقق الآن',
checking: 'جار التحقق...',
seeWhatsNew: 'عرض الجديد',
updateNow: 'تحديث الآن',
releaseNotes: 'ملاحظات الإصدار',
onLatest: 'أنت على أحدث إصدار',
installing: 'جار التثبيت...',
cantUpdate: 'تعذر التحديث',
cantReach: 'تعذر الوصول لخدمة التحديث',
tapCheck: 'اضغط للتحقق من التحديثات.',
updateReady: count => `${count} تحديث متاح`,
updateReadyUnknown: 'تحديث جديد جاهز.',
lastChecked: age => `آخر تحقق ${age}`,
justNowSuffix: 'الآن',
automaticUpdates: 'التحديثات التلقائية',
automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.',
branchCommit: (branch, commit) => `${branch} عند ${commit}`,
never: 'أبدا',
justNow: 'الآن',
minAgo: count => `قبل ${count} دقيقة`,
hoursAgo: count => `قبل ${count} ساعة`,
daysAgo: count => `قبل ${count} يوم`
},
config: {
none: 'لا شيء',
noneParen: '(لا شيء)',
@@ -2008,6 +1983,42 @@ export const ar = defineLocale({
tryAgain: 'إعادة المحاولة',
notAvailableTitle: 'التحديث غير متاح',
unsupportedMessage: 'لا يمكن لهذا الإصدار من Hermes تحديث نفسه من داخل التطبيق.',
appName: 'Hermes Desktop',
version: value => `الإصدار ${value}`,
versionUnavailable: 'الإصدار غير متاح',
bundleOutOfSync: 'إصدار التطبيق قديم',
bundleOutOfSyncDesc:
'تم تحديث وقت تشغيل Hermes، لكن تطبيق سطح المكتب نفسه لا يزال إصدارًا قديمًا — لن تظهر ميزات الواجهة الجديدة (مثل Bot Mode) حتى يتم تحديث التطبيق. شغّل التحديث أدناه لإعادة بناء التطبيق. إذا لم يختفِ هذا التحذير، فأعد التثبيت من أحدث مثبّت لسطح المكتب.',
bundleOutOfSyncAction: 'الحصول على المثبّت',
updatesSection: 'التحديثات',
checkNow: 'التحقق الآن',
checkingShort: 'جار التحقق...',
seeWhatsNew: 'عرض الجديد',
releaseNotes: 'ملاحظات الإصدار',
installing: 'جار التثبيت...',
cantReach: 'تعذر الوصول لخدمة التحديث',
tapCheck: 'اضغط للتحقق من التحديثات.',
updateReady: count => `${count} تحديث متاح`,
updateReadyUnknown: 'تحديث جديد جاهز.',
lastChecked: age => `آخر تحقق ${age}`,
justNowSuffix: 'الآن',
never: 'أبدا',
justNow: 'الآن',
minAgo: count => `قبل ${count} دقيقة`,
hoursAgo: count => `قبل ${count} ساعة`,
daysAgo: count => `قبل ${count} يوم`,
versionDetailsTitle: 'تفاصيل الإصدار',
versionDetailsBody: 'هذا التثبيت مُدار خارج التطبيق. حدّثه بالطريقة نفسها التي استخدمتها لتثبيته.',
versionDetailsVersion: 'الإصدار',
versionDetailsCommit: 'الالتزام',
versionDetailsBuildOrigin: 'مصدر البناء',
versionDetailsDistribution: 'التوزيع',
versionDetailsDistributionDesktop: 'تطبيق سطح المكتب',
versionDetailsRuntimeEmbedded: 'وقت تشغيل مضمّن',
versionDetailsRuntimeExternal: 'خارجي (يستخدم وقت تشغيل الجهاز)',
versionDetailsRuntime: 'وقت التشغيل',
versionDetailsInstallId: 'معرّف التثبيت',
versionDetailsUncommittedChanges: '(+ تغييرات غير ملتزمة)',
connectionRetry: 'تحقق من اتصالك وأعد المحاولة.',
latestBody: 'أنت تستخدم أحدث إصدار.',
latestBodyBackend: 'الواجهة الخلفية تعمل بأحدث إصدار.',
@@ -2017,6 +2028,7 @@ export const ar = defineLocale({
availableTitleBackend: 'يتوفر تحديث للواجهة الخلفية',
availableBodyBackend: 'إصدار أحدث من واجهة Hermes الخلفية المتصلة جاهز للتثبيت.',
availableBodyNoChangelog: 'إصدار أحدث جاهز. ملاحظات الإصدار غير متاحة لنوع التثبيت هذا.',
availableBodyRelease: tag => `Hermes ${tag} جاهز للتثبيت.`,
updateNow: 'التحديث الآن',
maybeLater: 'ربما لاحقا',
moreChanges: count => `+ ${count} تغيير${count === 1 ? '' : 'ات'} إضافي مُضمَّن.`,
@@ -2255,6 +2267,7 @@ export const ar = defineLocale({
update: 'تحديث',
updateInProgress: 'التحديث جار',
commitsBehind: (count, branch) => `${count} commits خلف ${branch}`,
releaseAvailable: tag => `الإصدار ${tag} متاح`,
desktopVersion: version => `سطح المكتب ${version}`,
backendVersion: version => `الخلفية ${version}`,
clientLabel: version => `العميل ${version}`,
+10
View File
@@ -142,6 +142,8 @@ export const en: Translations = {
updateReadyTitle: 'Update ready',
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
updateReadyMessageUnknown: 'A new update is available.',
updateReadyMessageAppInstaller:
'A new version of Hermes is ready. Update now and Windows will finish it for you.',
seeWhatsNew: "See what's new",
mcp: {
needsAuthTitle: 'MCP server needs re-authentication',
@@ -2594,6 +2596,8 @@ export const en: Translations = {
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
availableBodyAppInstaller:
'A new version of Hermes is ready. Hermes will close, Windows will finish the update, and Hermes will reopen on its own.',
updateNow: 'Update now',
maybeLater: 'Maybe later',
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
@@ -2611,6 +2615,12 @@ export const en: Translations = {
applyingBodyBackend:
'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.',
applyingClose: 'This window will close while the update runs, then Hermes reopens on its own.',
applyingBodyAppInstaller:
'Hermes will close and Windows will finish the update. Hermes will reopen when it’s done — you don’t need to do anything.',
applyingCloseAppInstaller: 'This window will close, Windows finishes the update, and Hermes reopens on its own.',
checkUnknownTitleAppInstaller: 'Couldn’t check for updates',
checkUnknownBodyAppInstaller:
'Windows couldn’t check for updates right now. Updates also install automatically when you restart Hermes.',
errorTitle: 'Update didn’t finish',
errorBody: 'No worries — nothing was lost. You can try again now.',
blockerTitle: 'Close local previews to update Hermes?',
+6
View File
@@ -183,6 +183,7 @@ export interface Translations {
updateReadyTitle: string
updateReadyMessage: (count: number) => string
updateReadyMessageUnknown: string
updateReadyMessageAppInstaller: string
seeWhatsNew: string
mcp: {
needsAuthTitle: string
@@ -2194,6 +2195,7 @@ export interface Translations {
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
availableBodyAppInstaller: string
updateNow: string
maybeLater: string
moreChanges: (count: number) => string
@@ -2210,6 +2212,10 @@ export interface Translations {
applyingBody: string
applyingBodyBackend: string
applyingClose: string
applyingBodyAppInstaller: string
applyingCloseAppInstaller: string
checkUnknownTitleAppInstaller: string
checkUnknownBodyAppInstaller: string
errorTitle: string
errorBody: string
blockerTitle: string
+6
View File
@@ -138,6 +138,7 @@ export const zh: Translations = {
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
updateReadyMessageUnknown: '有新更新可用。',
updateReadyMessageAppInstaller: 'Hermes 新版本已就绪。现在更新,Windows 会自动完成。',
seeWhatsNew: '查看更新内容',
mcp: {
needsAuthTitle: 'MCP 服务器需要重新认证',
@@ -2772,6 +2773,7 @@ export const zh: Translations = {
availableTitleBackend: '后端有可用更新',
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
availableBodyAppInstaller: 'Hermes 新版本已就绪。Hermes 会关闭,Windows 完成更新后自动重新打开。',
updateNow: '立即更新',
maybeLater: '稍后再说',
moreChanges: count => `另有 ${count} 项更改。`,
@@ -2788,6 +2790,10 @@ export const zh: Translations = {
'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
applyingClose: '此窗口会在更新期间关闭,随后 Hermes 会自动重新打开。',
applyingBodyAppInstaller: 'Hermes 会关闭,Windows 会完成更新,然后 Hermes 自动重新打开——无需任何操作。',
applyingCloseAppInstaller: '此窗口会关闭,Windows 完成更新后 Hermes 自动重新打开。',
checkUnknownTitleAppInstaller: '无法检查更新',
checkUnknownBodyAppInstaller: 'Windows 目前无法检查更新。重启 Hermes 时也会自动安装更新。',
errorTitle: '更新未完成',
errorBody: '没有数据丢失。你可以现在重试。',
blockerTitle: '关闭本地预览以更新 Hermes?',
+7
View File
@@ -0,0 +1,7 @@
/**
* docs.ts — canonical docs-site links used by the renderer's recovery and
* setup surfaces. Single authority so the "reinstall the app" affordance
* (bundled installs with a damaged payload) and any future doc links point
* at the same place.
*/
export const DESKTOP_DOCS_URL = 'https://hermes-agent.nousresearch.com/docs/user-guide/desktop'
+42 -1
View File
@@ -7,7 +7,10 @@ const copy = {
availableBody: 'A new version of Hermes is ready to install.',
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.'
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
availableBodyRelease: (tag: string) => `Hermes ${tag} is ready to install.`,
availableBodyAppInstaller:
'A new version of Hermes is ready. Hermes will close, Windows will finish the update, and Hermes will reopen on its own.'
}
describe('resolveUpdateCopy', () => {
@@ -35,4 +38,42 @@ describe('resolveUpdateCopy', () => {
expect(r.title).toBe('New update available')
expect(r.body).toBe(copy.availableBodyNoChangelog)
})
it('stable channel with a known tag names the release, not the commit count', () => {
const r = resolveUpdateCopy({
target: 'client',
shownItems: 0,
channel: 'stable',
latestTag: 'v0.18.0',
copy
})
expect(r.body).toBe('Hermes v0.18.0 is ready to install.')
})
it('stable channel without a tag falls back to the generic release body', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, channel: 'stable', copy })
expect(r.body).toBe(copy.availableBody)
})
it('app-installer mechanism uses the Windows-finishes-it body, not commit vocabulary', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'app-installer', copy })
expect(r.body).toBe(copy.availableBodyAppInstaller)
})
it('app-installer mechanism with a known tag names the release', () => {
const r = resolveUpdateCopy({
target: 'client',
shownItems: 0,
mechanism: 'app-installer',
latestTag: 'v0.18.3',
copy
})
expect(r.body).toBe('Hermes v0.18.3 is ready to install.')
})
it('other mechanisms keep the commit vocabulary', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'windows-handoff', copy })
expect(r.body).toBe(copy.availableBody)
})
})
+40 -1
View File
@@ -4,6 +4,8 @@
* Names the update target (client vs the connected backend in remote mode) and
* degrades honestly when there's no commit changelog to show (e.g. a pip /
* non-git backend where `git log` yields nothing) instead of generic filler.
* On a release-feed channel it names the release tag instead of commit
* vocabulary, because a release feed carries no commit rows by construction.
*
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
*/
@@ -16,12 +18,29 @@ export interface UpdateCopyStrings {
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
/** Stable channel: names the release instead of describing commits. */
availableBodyRelease: (tag: string) => string
/** App-installer mechanism: the OS owns the apply, so the body says so. */
availableBodyAppInstaller: string
}
export interface ResolveUpdateCopyInput {
target: UpdateTarget
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
shownItems: number
/**
* 'stable': the update is a release, so the body names its tag and the
* commit-changelog wording never appears (a release feed carries no
* commit rows anyway). 'main' or absent: commit vocabulary as before.
*/
channel?: 'stable' | 'main'
/** Stable channel: the release tag the update moves to, when known. */
latestTag?: null | string
/**
* 'app-installer': the OS App Installer owns the apply (out-of-store MSIX)
* — the body names Windows as the finisher, never commit vocabulary.
*/
mechanism?: 'app-installer' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual'
copy: UpdateCopyStrings
}
@@ -30,9 +49,29 @@ export interface UpdateCopyResult {
body: string
}
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
export function resolveUpdateCopy({
target,
shownItems,
channel = 'main',
latestTag = null,
mechanism,
copy
}: ResolveUpdateCopyInput): UpdateCopyResult {
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
// App-installer: the OS owns the apply, and there is no commit list by
// construction (the OS checker answers yes/no). Name Windows as the
// finisher; the tag is named when known.
if (mechanism === 'app-installer') {
return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBodyAppInstaller }
}
if (channel === 'stable') {
// No-changelog copy would be wrong here: the absence of commit rows is
// structural on a release feed, not a degraded install type.
return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBody }
}
const body =
shownItems === 0
? copy.availableBodyNoChangelog
@@ -94,4 +94,22 @@ describe('resolveVersionStatus', () => {
it('hides a backend row that has no version at all', () => {
expect(backend().unknown).toBe(true)
})
it('stable channel: releases use the update word, never a commit count', () => {
const label = client({ behind: 4, updateAvailable: true, channel: 'stable', version: '0.4.2' }).label
expect(label).toBe('v0.4.2 (update)')
})
it('stable channel tooltip names the release tag', () => {
const tooltip = client({ behind: 1, channel: 'stable', latestTag: 'v0.18.0', version: '0.4.2' }).tooltip
expect(tooltip).toContain(`${copy.releaseAvailable('v0.18.0')}`)
})
it('stable channel never names a branch (a stable checkout sits on a tag)', () => {
const tooltip = client({ behind: 1, branch: 'main', channel: 'stable', latestTag: 'v0.18.0', version: '0.4.2' })
.tooltip
expect(tooltip).not.toContain('main')
expect(tooltip).toContain(`${copy.releaseAvailable('v0.18.0')}`)
})
})
+26 -6
View File
@@ -18,6 +18,8 @@ export interface VersionStatusCopy {
commit: (sha: string) => string
commitsBehind: (count: number, branch: string) => string
desktopVersion: (version: string) => string
/** Stable channel: a newer release exists ("v0.21.0 is available"). */
releaseAvailable: (tag: string) => string
restart: string
unknown: string
update: string
@@ -31,7 +33,17 @@ export interface VersionStatusInput {
applyMessage?: string
behind?: number
branch?: string
/**
* The update channel of the target. 'main' (the default) speaks in
* commits behind a branch. 'stable' speaks in releases: the label hint
* is the update word, never a commit count, and the tooltip names the
* newer release tag. The channel changes the vocabulary only — the
* apply mechanism is the caller's concern.
*/
channel?: 'stable' | 'main'
copy: VersionStatusCopy
/** Stable channel: the newest release tag, when the check found one. */
latestTag?: null | string
/** Remote mode: the client is one of two versions on screen, so it says so. */
remote: boolean
/** The apply reached the restart stage — labels `restart`, not `update`. */
@@ -60,7 +72,9 @@ export function resolveVersionStatus({
applying,
behind = 0,
branch,
channel = 'main',
copy,
latestTag = null,
remote,
restarting,
sha = null,
@@ -75,6 +89,7 @@ export function resolveVersionStatus({
// SSH-official presence-only checks, and pip installs. It applies to BOTH
// targets — the client statusbar item is how a shallow desktop install
// learns it's stale at all.
const stable = channel === 'stable'
const available = behind > 0 || !!updateAvailable
// A client with no version still identifies itself by sha; a backend can't.
@@ -86,17 +101,22 @@ export function resolveVersionStatus({
? copy.clientLabel(named)
: (version && `v${version}`) || named
// Commits behind is the precise diff; `(update)` is the fallback for a
// backend that knows it's stale but can't count (pip, non-git checkout).
const hint = busy ? '' : behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
// Main channel: commits behind is the precise diff. Stable channel: a
// count of commits is the wrong vocabulary — a release is one step, so
// the hint is always the update word. `(update)` also covers a backend
// that knows it's stale but can't count (pip, non-git checkout).
const hint = busy ? '' : !stable && behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
const tooltip = [
busy && (applyMessage || copy.updateInProgress),
!busy && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
!busy && behind <= 0 && available && copy.update,
!busy && available && stable && latestTag && copy.releaseAvailable(latestTag),
!busy && !stable && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
!busy && available && (stable ? !latestTag : behind <= 0) && copy.update,
version && (client ? copy.desktopVersion(version) : copy.backendVersion(version)),
client && sha && copy.commit(sha),
client && branch && copy.branch(branch)
// The branch line is main-channel vocabulary; a stable checkout sits on
// a tag, and naming a branch would contradict the release line.
client && !stable && branch && copy.branch(branch)
]
.filter(Boolean)
.join(' · ')
+27 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, expect, test } from 'vitest'
import { beforeEach, expect, test, vi } from 'vitest'
import { $notifications, clearNotifications, isDiskFullErrorMessage, notifyError } from './notifications'
@@ -55,3 +55,29 @@ test('session storage write failure is treated as disk-full class', () => {
expect(lastMessage()).toMatch(/Disk full/i)
})
test('notifyError posts the full error to desktop.log, not the summary', () => {
const logLine = vi.fn()
const previous = (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
;(window as unknown as { hermesDesktop: { logLine: typeof logLine } }).hermesDesktop = { logLine }
try {
const error = new Error('sqlite3.OperationalError: database is locked')
error.stack = 'Error: sqlite3.OperationalError: database is locked\n at saveSession (session.ts:12)'
notifyError(error, 'Prompt failed')
expect(logLine).toHaveBeenCalledTimes(1)
expect(logLine.mock.calls[0][0]).toContain('Prompt failed')
expect(logLine.mock.calls[0][0]).toContain('database is locked')
expect(logLine.mock.calls[0][0]).toContain('session.ts:12')
} finally {
if (previous === undefined) {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
} else {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = previous
}
}
})
+16
View File
@@ -189,8 +189,24 @@ export function notify(input: NotificationInput): string {
return id
}
// The toast only ever shows the summarized copy (possibly a fallback at 180
// chars). desktop.log gets the FULL error — message and stack — so a bug
// report carries the actual failure, not the summary. Fire-and-forget: the
// toast must never depend on this round-trip.
function logErrorToDesktopLog(error: unknown, fallback: string) {
try {
const label = new URLSearchParams(window.location.search).get('win') ?? 'main'
const raw = error instanceof Error ? (error.stack ?? error.message) : typeof error === 'string' ? error : fallback
window.hermesDesktop?.logLine?.(`[renderer error:${label}] ${fallback}: ${raw}`)
} catch {
// Logging must never break the toast.
}
}
export function notifyError(error: unknown, fallback: string): string {
const readable = readableError(error, fallback)
logErrorToDesktopLog(error, fallback)
return notify({
kind: 'error',
+8 -6
View File
@@ -207,14 +207,14 @@ export function reportInstallMethodWarning(message: string | undefined): void {
* on every new commit. The snooze is persisted, so it survives relaunches too.
*/
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
if (!status || status.supported === false || status.error || !status.targetSha) {
// Either signal means "update ready": behind > 0 (git checkout) or
// updateAvailable (app-installer, shallow clone).
if (!status || status.supported === false || status.error) {
return
}
const behind = typeof status.behind === 'number' ? status.behind : null
// behind === null means "update available, exact count unknown" (shallow
// clone). That still deserves the toast — just with count-free copy.
if ((behind ?? 0) <= 0 && !status.updateAvailable) {
return
}
@@ -240,9 +240,11 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
id: UPDATE_TOAST_ID,
kind: 'info',
message:
behind !== null && behind > 0
? translateNow('notifications.updateReadyMessage', behind)
: translateNow('notifications.updateReadyMessageUnknown'),
status.mechanism === 'app-installer'
? translateNow('notifications.updateReadyMessageAppInstaller')
: behind !== null && behind > 0
? translateNow('notifications.updateReadyMessage', behind)
: translateNow('notifications.updateReadyMessageUnknown'),
onDismiss: () => snoozeUpdateToast(),
title: translateNow('notifications.updateReadyTitle')
})
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more