Files
hermes-agent/apps/desktop/electron-builder.config.cjs
ethernet 5fbe2afc14 fix(desktop): a stable-branded channel build runs with stable's userData
A channel that copies the stable identity carried the token that channel
identities need for the updater, so the runtime pinned appData/HermesBundled
and Electron's early productName became "Hermes Agent". Same app ID and name
as installed stable, but separate desktop settings and a separate
single-instance lock.

When the admitted request's identity equals the plain stable bundled identity,
export that identity without the token. The updater reads the token from the
stamped request, so channel updates are unaffected. The packaging config then
skips the productName override exactly as a tag build does.
2026-09-30 09:21:43 -04:00

333 lines
14 KiB
JavaScript

// THE electron-builder configuration — the whole thing, one file. There is
// no "build" field in package.json: run-electron-builder.mjs always passes
// --config for this file, so a stray package.json field would be silently
// ignored anyway, and splitting the config across JSON + this overlay is
// how the two halves drift.
//
// A .cjs module (not JSON) so the variant is decided at require time:
// HERMES_DESKTOP_VARIANT=light builds "Hermes Light". The whole config
// derives from that one flag.
// @ts-check
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const feedContract = require('./update-feed.cjs')
const { createMacSigner } = require('./scripts/mac-sign.mjs')
const {
light,
store,
storeMsix,
displayName,
appId,
appNamePascal,
artifactNamePascal,
windowsExecutableName,
channel,
msixAppIdWithOrg,
token
} = require('./product-identity.cjs')
// `storeMsix` is optional on the identity type but guaranteed present when
// `store` is true (product-identity.cjs spreads it only in that branch).
// The `store` flag is typed `boolean` in the identity, so checkJs cannot
// correlate the two; `mustStoreMsix` is the single assertion point and the
// invariant lives in product-identity.cjs:33-34/58-68.
/** @type {NonNullable<typeof storeMsix> | undefined} */
const storeMsixWhenStore = storeMsix
const releaseBuild = Boolean(process.env.HERMES_PAYLOAD_TAG)
/**
* The store MSIX packaging identity. Callers must only invoke this when
* `store` is true (see the invariant note above).
* @param {NonNullable<typeof storeMsix> | undefined} value
* @returns {NonNullable<typeof storeMsix>}
*/
function mustStoreMsix(value) {
return /** @type {NonNullable<typeof storeMsix>} */ (value)
}
// The out-of-store MSIX publisher (ATS cert subject) — single source, shared
// with the .appinstaller generator so the manifest and the App Installer can
// never drift (see scripts/msix-shared.mjs).
const { OUT_OF_STORE_PUBLISHER, channelBuildRequest, stageChannelManifest } = require('../../scripts/msix-shared.mjs')
const channelRequest = channelBuildRequest()
/** @typedef {import("app-builder-lib").Configuration} Configuration */
const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'NousResearch/hermes-agent').split('/')
if (!owner || !repo) {
throw new Error(`invalid GITHUB_REPOSITORY ${process.env.GITHUB_REPOSITORY}`)
}
const electronVersion = require('./package.json').devDependencies.electron
if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) {
throw new Error(`invalid electron version ${electronVersion} in package.json`)
}
const macFeed = channelRequest ? null : feedContract.darwinFeed(channel === 'canary' || channel === 'light-canary' ? 'canary' : 'stable', light)
const publicUrl = feedContract.feedBaseUrl(process.env.CLOUDFLARE_R2_PUBLIC_URL)
/** @satisfies {Configuration} */
module.exports = {
electronVersion,
appId,
productName: displayName,
executableName: displayName,
protocols: [
{
name: `${displayName} Protocol`,
schemes: ['hermes']
}
],
// A store build is archived, never served to a feed — prefix its artifact
// so it can't collide with the out-of-store MSIX of the same tag/arch, and
// the release pipeline can keep the two apart.
artifactName: `${store ? 'Store-' : ''}${artifactNamePascal}-\${version}-\${os}-\${arch}.\${ext}`,
icon: 'assets/icon',
// The electron-updater feed. CI builds set CLOUDFLARE_R2_PUBLIC_URL (the R2
// public bucket / custom domain) and publish there — the feed yml, blockmaps
// and installers all live in the same flat R2 bucket, and electron-updater
// resolves the yml's relative artifact paths against it. Builds without the
// var (local, or a fork without the R2 vars) keep the github provider, which
// is exactly today's behavior. The store build has no feed at all (the Store
// owns its distribution and updates).
publish: channelRequest ? null : !channel
? null
: [
publicUrl
? { provider: 'generic', url: publicUrl, channel }
: { provider: 'github', owner, repo, channel }
],
extraMetadata: {
name: appNamePascal,
// Electron bootstrap reads package.productName before main.ts. Keep the
// shipped stable default, but isolate nonstable userData from first access.
// A stable-branded channel has no token and keeps stable's userData.
...((channelRequest && token) || appNamePascal !== artifactNamePascal ? { productName: displayName } : {}),
desktopName: appId
},
directories: {
output: 'release'
},
files: ['dist/**', 'assets/**', 'public/**', 'package.json'],
beforeBuild: channelRequest ? async () => {
await require(path.join(__dirname, 'scripts/before-build.mjs')).default()
stageChannelManifest(__dirname, channelRequest)
return false
} : 'scripts/before-build.mjs',
beforePack: 'scripts/before-pack.mjs',
// The exe identity stamp runs here, on the pristine electron.exe, because
// ASAR integrity rewrites the PE later and rcedit cannot commit to that
// rewritten file (#105629). afterPack keeps the signing/payload work.
afterExtract: 'scripts/after-extract.mjs',
afterPack: 'scripts/after-pack.mjs',
...(process.platform === 'darwin' ? { afterSign: 'scripts/notarize.mjs' } : {}),
extraResources: [
{
from: 'build/install-stamp.json',
to: 'install-stamp.json'
},
...(['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT || '')
? [{ from: 'build/agent-payload', to: 'agent-payload' }]
: []),
{
from: 'assets/icon.ico',
to: 'icon.ico'
}
],
asar: {
unpack: ['**/*.node', '**/prebuilds/**', 'dist/**']
},
mac: {
// The afterSign hook owns notarization, including keychain-profile builds.
notarize: false,
// The packaged client reads this generated app-update.yml by default.
publish: channelRequest?.receiverCandidate
? [{ provider: 'generic', url: `${channelRequest.publicBase}/releases/darwin/stable/`, channel: 'stable' }]
: channelRequest
? [{ provider: 'generic', url: `${channelRequest.publicBase}/releases/channel-builds/${channelRequest.buildId}/darwin/`, channel: 'latest' }]
: publicUrl && channel && macFeed
? [{ provider: 'generic', url: `${publicUrl}/${macFeed.directory}/`, channel: macFeed.channel }]
: null,
category: 'public.app-category.developer-tools',
extendInfo: {
CFBundleDisplayName: displayName,
CFBundleExecutable: displayName,
CFBundleName: displayName,
LSRequiresNativeExecution: true,
NSAudioCaptureUsageDescription: `${displayName} uses audio capture for voice conversations.`,
NSCameraUsageDescription: `${displayName} uses the camera when a plugin or feature you enable requests it.`,
NSMicrophoneUsageDescription: `${displayName} uses the microphone for voice input and voice conversations.`,
NSCalendarsUsageDescription: `${displayName} needs access to Calendar to provide requested meeting and scheduling support.`,
NSCalendarsFullAccessUsageDescription: `${displayName} needs full access to Calendar to read and manage events when explicitly requested.`,
NSRemindersUsageDescription: `${displayName} needs access to Reminders to provide requested personal-assistant and scheduling support.`,
NSRemindersFullAccessUsageDescription: `${displayName} needs full access to Reminders to read and manage reminders when explicitly requested.`,
NSScreenCaptureUsageDescription: `${displayName} captures the screen when you ask the agent to screenshot or record it.`,
NSLocalNetworkUsageDescription: `${displayName} connects to devices on your local network when a plugin or feature you enable requests it.`,
NSAppleMusicUsageDescription: `${displayName} accesses your music library when a plugin or feature you enable requests it.`,
NSContactsUsageDescription: `${displayName} uses Contacts access when you ask it to read or update your address book.`,
NSAppleEventsUsageDescription: `${displayName} uses Apple Events to automate apps you explicitly ask it to control.`
},
target: ['dmg', 'zip'],
sign: createMacSigner({
entitlements: path.join(__dirname, 'electron/entitlements.mac.plist'),
entitlementsInherit: path.join(__dirname, 'electron/entitlements.mac.inherit.plist'),
hardenedRuntime: true,
ignore: (/** @type {string} */ file) => {
try {
if (fs.lstatSync(file).isDirectory()) {
return false
}
return !isMachO(file)
} catch {
return true
}
}
})
},
dmg: {
// Avoid the failing optional APFS shrink pass; keep compressed conversion.
shrink: false,
title: 'Hermes Agent Installer',
// A prebuilt .tiff on purpose, not a PNG plus a @2x sibling: dmg-builder's
// PNG path runs `tiffutil -cathidpicheck`, which on macOS 26 rewrites both
// frames to 72 dpi and silently drops the 2x representation. A .tiff is
// handed to dmgbuild untouched (dmg-builder/dist/dmgUtil.js), and living
// outside assets/ keeps it out of the app bundle via the `files` whitelist.
background: 'packaging/nous-dmg-2b.tiff',
iconSize: 96,
iconTextSize: 11,
window: {
width: 660,
height: 400
},
contents: [
{
x: 253,
y: 238,
type: 'file'
},
{
x: 512,
y: 235,
type: 'link',
path: '/Applications'
}
]
},
win: {
executableName: windowsExecutableName,
legalTrademarks: displayName,
target: ['msix'],
...windowsSigning()
},
msix: {
// A store build uses the Partner Center packaging identity (the Store
// re-signs + rewrites the publisher on submission); everything else uses
// the out-of-store ATS-cert identity.
identityName: store ? mustStoreMsix(storeMsixWhenStore).identityName : msixAppIdWithOrg,
applicationId: appNamePascal,
displayName,
publisher: store ? mustStoreMsix(storeMsixWhenStore).publisher : OUT_OF_STORE_PUBLISHER,
publisherDisplayName: store ? mustStoreMsix(storeMsixWhenStore).publisherDisplayName : 'Nous Research',
// The native quad is the build time (scripts/msix-shared.mjs::nativeQuad),
// baked into the manifest template, so the builder's own build-number
// override would stamp a second, conflicting version.
setBuildNumber: false,
// Store versions are baked into a build-time template. App semver and
// artifact filenames stay unchanged; the Store reserves revision zero.
// Floor Windows 11 22H2. Below build 18307 the manifest schema caps
// AppExtension Name at 39 chars and Microsoft's own
// "com.microsoft.windows.copilotkeyprovider" is 40 (makeappx
// 0x80080204 — A/B-verified against the 26100 kit; 18307 exactly
// still failed on it, 22621 passes), and 22621 is the documented
// Copilot hardware key floor anyway.
minVersion: '10.0.22621.0',
maxVersionTested: '10.0.26100.0',
// Static path: the file itself is written by scripts/before-build.mjs at
// build time (see the comment on the hook) — never at config require
// time, so typecheck/test imports don't touch the filesystem.
customExtensionsPath: 'build/msix-extensions.xml',
customManifestPath: store ? 'build/store-msix-manifest.xml'
: releaseBuild || channelRequest || appNamePascal !== artifactNamePascal
? 'build/msix-manifest.xml' : 'assets/msix-manifest.xml',
// Hermes state is deliberately shared with unpackaged CLI/gateway
// processes. Pair the manifest's disabled virtualization properties with
// the restricted capability that permits unvirtualized AppData/HKCU writes.
capabilities: ['unvirtualizedResources'],
showNameOnTiles: true
},
linux: {
category: 'Development',
maintainer: 'Nous Research <support@nousresearch.com>',
synopsis: light
? 'Remote-only desktop client for Hermes Agent.'
: 'Native desktop shell for Hermes Agent.',
target: ['AppImage']
}
}
if (channelRequest) {
Object.assign(module.exports, { buildVersion: channelRequest.version })
Object.assign(module.exports.extraMetadata, {
version: channelRequest.version,
shortVersion: channelRequest.windowsVersion,
shortVersionWindows: channelRequest.windowsVersion
})
Object.assign(module.exports.mac, {
bundleVersion: channelRequest.version,
bundleShortVersion: channelRequest.version
})
}
// MSIX build-time staging (build/appx icons + build/msix-extensions.xml)
// lives in scripts/before-build.mjs — an electron-builder lifecycle hook —
// NOT here at require time, so importing this config for typecheck/tests
// never writes to the filesystem.
// Azure Trusted Signing. The hook signs the .msix package itself and the
// product exe (after electron-builder's rcedit — the batch in afterPack runs
// too early for the exe); every other payload binary is batch-signed in
// afterPack via scripts/batch-sign-binaries.mjs, which this hook acknowledges
// with `true` so electron-builder never re-signs one-by-one. The package
// signature is all Windows install validation checks; inner Authenticode
// covers SmartScreen/WDAC tree scans. See batch-sign-binaries.mjs for the
// ordering contract.
const MACHO_MAGICS = new Set([
0xfeedface,
0xcefaedfe,
0xfeedfacf,
0xcffaedfe,
0xcafebabe,
0xbebafeca
])
/** @param {string} file */
function isMachO(file) {
const buf = Buffer.alloc(4)
const fd = fs.openSync(file, 'r')
try {
if (fs.readSync(fd, buf, 0, 4, 0) !== 4) {
return false
}
} finally {
fs.closeSync(fd)
}
return MACHO_MAGICS.has(buf.readUInt32BE(0))
}
function windowsSigning() {
if (!process.env.AZURE_SIGN_ENDPOINT || !process.env.AZURE_CLIENT_ID) {
return {}
}
return {
sign: {
type: 'signtool',
sign: './scripts/batch-sign-binaries.mjs',
signingHashAlgorithms: ['sha256'],
publisherName: process.env.AZURE_SIGN_PUBLISHER
}
}
}