diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 644f9405056e8..373fee568d066 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -50,6 +50,31 @@ function hiddenWindowsChildOptions(options = {}) { } const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i +const FALLBACK_COMMIT_RE = /^0{7,40}$/ +const FALLBACK_BRANCH = 'main' + +function isPinnedCommit(commit) { + return typeof commit === 'string' && STAMP_COMMIT_RE.test(commit) && !FALLBACK_COMMIT_RE.test(commit) +} + +function installRefForStamp(installStamp) { + if (installStamp && isPinnedCommit(installStamp.commit)) { + return { + ref: installStamp.commit, + cacheKey: installStamp.commit, + pinned: true + } + } + if (installStamp && typeof installStamp.commit === 'string' && FALLBACK_COMMIT_RE.test(installStamp.commit)) { + const ref = installStamp.branch || FALLBACK_BRANCH + return { + ref, + cacheKey: `fallback-${ref.replace(/[^0-9A-Za-z._-]/g, '_')}`, + pinned: false + } + } + return null +} // Stages flagged needs_user_input=true in the manifest are skipped by the // runner (passed -NonInteractive to install.ps1, which the install script @@ -104,12 +129,13 @@ function cachedScriptPath(hermesHome, commit) { return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`) } -function downloadInstallScript(commit, destPath) { - // Fetch from GitHub raw at the pinned commit. The raw URL with a SHA - // is immutable (unlike a branch ref), so we don't need integrity - // verification beyond "did the file we wrote pass a syntax probe." +function downloadInstallScript(ref, destPath) { + // Fetch from GitHub raw at the install ref. Normal production builds pass a + // pinned SHA. Non-git fallback builds pass an unpinned branch ref so local + // builds can still bootstrap without pretending the all-zero placeholder is + // a real GitHub commit. const scriptName = installScriptName() - const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}` + const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${ref}/scripts/${scriptName}` return new Promise((resolve, reject) => { fs.mkdirSync(path.dirname(destPath), { recursive: true }) const tmpPath = destPath + '.tmp' @@ -190,33 +216,38 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, } // 2. Packaged path: download from GitHub at the pinned commit (1B's stamp). - if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) { + // Non-git fallback builds carry an all-zero commit; treat it as an + // unpinned branch ref instead of trying to fetch a non-existent SHA. + const installRef = installRefForStamp(installStamp) + if (!installRef) { throw new Error( `Cannot resolve ${installScriptName()}: no SOURCE_REPO_ROOT and no install stamp. ` + 'This packaged build was produced without a valid build-time stamp.' ) } - const cached = cachedScriptPath(hermesHome, installStamp.commit) + const cached = cachedScriptPath(hermesHome, installRef.cacheKey) try { await fsp.access(cached, fs.constants.R_OK) emit({ type: 'log', - line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` + line: `[bootstrap] using cached ${installScriptName()} for ${installRef.ref.slice(0, 12)}` }) - return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'cache', commit: installRef.pinned ? installRef.ref : null, kind: installScriptKind() } } catch { // not cached; download } emit({ type: 'log', - line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` + line: + `[bootstrap] fetching ${installScriptName()} for ${installRef.ref.slice(0, 12)} from GitHub` + + (installRef.pinned ? '' : ' (fallback, unpinned)') }) try { - await _download(installStamp.commit, cached) + await _download(installRef.ref, cached) emit({ type: 'log', line: `[bootstrap] saved to ${cached}` }) - return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'download', commit: installRef.pinned ? installRef.ref : null, kind: installScriptKind() } } catch (err) { // The pinned commit may not be fetchable from GitHub -- most commonly a // locally-built desktop app stamped to an unpushed HEAD (see @@ -234,10 +265,10 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, try { fs.mkdirSync(path.dirname(cached), { recursive: true }) fs.copyFileSync(installed, cached) - return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } + return { path: cached, source: 'installed-agent', commit: installRef.pinned ? installRef.ref : null, kind: installScriptKind() } } catch { // Cache copy failed (read-only FS, etc.) -- use the source path directly. - return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } + return { path: installed, source: 'installed-agent', commit: installRef.pinned ? installRef.ref : null, kind: installScriptKind() } } } throw err @@ -446,7 +477,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome // instead of falling back to install.ps1's default ($Branch = "main"). function buildPinArgs(installStamp) { const args = [] - if (installStamp && installStamp.commit) { + if (installStamp && isPinnedCommit(installStamp.commit)) { args.push('-Commit', installStamp.commit) } if (installStamp && installStamp.branch) { @@ -460,7 +491,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) { if (installStamp && installStamp.branch) { args.push('--branch', installStamp.branch) } - if (installStamp && installStamp.commit) { + if (installStamp && isPinnedCommit(installStamp.commit)) { args.push('--commit', installStamp.commit) } return args @@ -700,7 +731,7 @@ async function runBootstrap(opts) { // 4. Write the bootstrap-complete marker. const markerPayload = { - pinnedCommit: installStamp ? installStamp.commit : null, + pinnedCommit: installStamp && isPinnedCommit(installStamp.commit) ? installStamp.commit : null, pinnedBranch: installStamp ? installStamp.branch : null } const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload @@ -725,5 +756,9 @@ module.exports = { resolveLocalInstallScript, resolveInstallScript, installedAgentInstallScript, - cachedScriptPath + cachedScriptPath, + buildPinArgs, + buildPosixPinArgs, + installRefForStamp, + isPinnedCommit } diff --git a/apps/desktop/electron/bootstrap-runner.test.cjs b/apps/desktop/electron/bootstrap-runner.test.cjs index 2e25aaf8919f8..4e9651609ec77 100644 --- a/apps/desktop/electron/bootstrap-runner.test.cjs +++ b/apps/desktop/electron/bootstrap-runner.test.cjs @@ -8,10 +8,15 @@ const { runBootstrap, resolveInstallScript, installedAgentInstallScript, - cachedScriptPath + cachedScriptPath, + buildPinArgs, + buildPosixPinArgs, + installRefForStamp, + isPinnedCommit } = require('./bootstrap-runner.cjs') const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' +const ZERO_COMMIT = '0000000000000000000000000000000000000000' function mkTmpHome() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-')) @@ -80,6 +85,57 @@ test('resolveInstallScript prefers a cached script without touching the network' } }) +test('fallback install stamps use an unpinned branch ref', () => { + const stamp = { commit: ZERO_COMMIT, branch: 'main' } + + assert.equal(isPinnedCommit(ZERO_COMMIT), false) + assert.deepEqual(installRefForStamp(stamp), { + ref: 'main', + cacheKey: 'fallback-main', + pinned: false + }) + assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main']) + assert.deepEqual(buildPosixPinArgs({ installStamp: stamp, activeRoot: '/tmp/hermes', hermesHome: '/tmp/home' }), [ + '--dir', + '/tmp/hermes', + '--hermes-home', + '/tmp/home', + '--branch', + 'main' + ]) +}) + +test('resolveInstallScript downloads fallback stamps by branch instead of zero commit', async () => { + const home = mkTmpHome() + try { + const logs = [] + const refs = [] + const result = await resolveInstallScript({ + installStamp: { commit: ZERO_COMMIT, branch: 'main' }, + sourceRepoRoot: null, + hermesHome: home, + emit: ev => logs.push(ev), + _download: async (ref, destPath) => { + refs.push(ref) + fs.mkdirSync(path.dirname(destPath), { recursive: true }) + fs.writeFileSync(destPath, '#!/bin/sh\necho fallback branch\n') + return destPath + } + }) + + assert.deepEqual(refs, ['main']) + assert.equal(result.source, 'download') + assert.equal(result.commit, null) + assert.equal(result.path, cachedScriptPath(home, 'fallback-main')) + assert.ok( + logs.some(ev => /fallback, unpinned/.test(ev.line || '')), + 'emits an unpinned fallback log line' + ) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => { const home = mkTmpHome() try { diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.cjs index 72b978c5f9a36..8457fa391b9f2 100644 --- a/apps/desktop/scripts/write-build-stamp.cjs +++ b/apps/desktop/scripts/write-build-stamp.cjs @@ -13,17 +13,17 @@ * "branch": "", * "builtAt": "", * "dirty": true|false, - * "source": "ci" | "local" + * "source": "ci" | "local" | "fallback" * } * * Source preference order: * 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with * shallow clones, detached HEADs, etc. in CI. * 2. Local `git rev-parse` against the parent repo (../..). + * 3. Fallback stamp for local/personal builds from non-git source trees. * - * Dev / out-of-repo builds without git produce an explicit error rather than - * silently writing an unstamped manifest -- the packaged app refuses to - * bootstrap without a stamp. + * Dev / out-of-repo builds without git produce an explicit fallback stamp + * rather than aborting the whole build. */ const fs = require("fs") @@ -77,21 +77,44 @@ function fromLocalGit() { } } +function fromFallback() { + // Non-git builds (ZIP download, bootstrap installer without .git) cannot + // determine a real commit. Use a placeholder so local/personal builds + // can still complete. The desktop bootstrap treats the all-zero commit as + // "unknown" and falls back to an unpinned branch bootstrap instead of trying + // to fetch a non-existent GitHub commit. + return { + commit: "0000000000000000000000000000000000000000", + branch: "main", + dirty: false, + source: "fallback" + } +} + function main() { - const stamp = fromCI() || fromLocalGit() + const stamp = fromCI() || fromLocalGit() || fromFallback() if (!stamp || !stamp.commit) { + // Should not happen — fromFallback() always provides a commit. console.error( "[write-build-stamp] ERROR: could not determine git commit.\n" + " - $GITHUB_SHA not set\n" + " - `git rev-parse HEAD` failed at " + - REPO_ROOT + - "\n" + + REPO_ROOT + "\n" + "Packaged builds require a git ref to pin first-launch install.ps1\n" + "against. Run from a git checkout or set $GITHUB_SHA explicitly." ) process.exit(1) } + if (stamp.commit === "0000000000000000000000000000000000000000") { + console.warn( + "[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" + + " Using placeholder commit — the packaged app will fall back to the\n" + + " default branch for first-launch bootstrap. For production builds,\n" + + " run from a git checkout." + ) + } + if (stamp.dirty) { console.warn( "[write-build-stamp] WARNING: working tree is dirty.\n" +