From 02aad08acf4633a45d0b50e4f270e9df4681d9a3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:06:34 -0700 Subject: [PATCH] fix(desktop): bootstrap falls back to installed agent install.sh on GitHub 404 Packaged Desktop first-launch bootstrap no longer dies with a fatal HTTP 404 when install-stamp.json pins a commit that isn't fetchable from GitHub. This only happens for locally-built desktop apps: write-build-stamp.cjs's fromLocalGit() pins `git rev-parse HEAD`, which can be an unpushed commit or dirty tree. CI builds stamp $GITHUB_SHA and are unaffected. The fix unblocks the dev / self-builder workflow. resolveInstallScript() now wraps the GitHub download in try/catch; on failure it resolves ~/.hermes/hermes-agent/scripts/install.sh (the already-installed agent checkout), copies it into bootstrap-cache, and returns it as source 'installed-agent'. If the cache copy fails (read-only FS), it uses the source path directly. With no installed checkout to fall back to, the original error rethrows unchanged. Download is now injectable via an optional _download param so the fallback path is tested hermetically (no network). Reported with a precise repro and suggested fix by @Tamaz-sujashvili (#40815). Co-authored-by: Tamaz-sujashvili <56168197+Tamaz-sujashvili@users.noreply.github.com> --- apps/desktop/electron/bootstrap-runner.cjs | 51 +++++++- .../electron/bootstrap-runner.test.cjs | 113 +++++++++++++++++- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index de96187c246..95c43c95521 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -76,6 +76,21 @@ function bootstrapCacheDir(hermesHome) { return path.join(hermesHome, 'bootstrap-cache') } +// The install.sh / install.ps1 that ships inside the already-installed agent +// checkout under ~/.hermes/hermes-agent. Used as a last-resort fallback when +// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop +// app stamped to an unpushed HEAD). +function installedAgentInstallScript(hermesHome) { + if (!hermesHome) return null + const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName()) + try { + fs.accessSync(candidate, fs.constants.R_OK) + return candidate + } catch { + return null + } +} + function cachedScriptPath(hermesHome, commit) { return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`) } @@ -155,7 +170,7 @@ function downloadInstallScript(commit, destPath) { }) } -async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) { +async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) { // 1. Dev shortcut: prefer a local checkout's installer so we can iterate // without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve // of APP_ROOT/../..). @@ -189,9 +204,35 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` }) - await downloadInstallScript(installStamp.commit, cached) - emit({ type: 'log', line: `[bootstrap] saved to ${cached}` }) - return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() } + try { + await _download(installStamp.commit, cached) + emit({ type: 'log', line: `[bootstrap] saved to ${cached}` }) + return { path: cached, source: 'download', commit: installStamp.commit, 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 + // write-build-stamp.cjs fromLocalGit). Fall back to the installer that + // ships inside the already-installed agent checkout so dev/self-builds can + // still bootstrap instead of dying with a fatal 404. + const installed = installedAgentInstallScript(hermesHome) + if (installed) { + emit({ + type: 'log', + line: + `[bootstrap] GitHub fetch failed (${err.message}); ` + + `falling back to installed agent ${installScriptName()} at ${installed}` + }) + try { + fs.mkdirSync(path.dirname(cached), { recursive: true }) + fs.copyFileSync(installed, cached) + return { path: cached, source: 'installed-agent', commit: installStamp.commit, 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() } + } + } + throw err + } } // --------------------------------------------------------------------------- @@ -673,5 +714,7 @@ module.exports = { // Exposed for testability parseStageResult, resolveLocalInstallScript, + resolveInstallScript, + installedAgentInstallScript, cachedScriptPath } diff --git a/apps/desktop/electron/bootstrap-runner.test.cjs b/apps/desktop/electron/bootstrap-runner.test.cjs index f105c735564..2e25aaf8919 100644 --- a/apps/desktop/electron/bootstrap-runner.test.cjs +++ b/apps/desktop/electron/bootstrap-runner.test.cjs @@ -1,7 +1,21 @@ const assert = require('node:assert/strict') const test = require('node:test') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') -const { runBootstrap } = require('./bootstrap-runner.cjs') +const { + runBootstrap, + resolveInstallScript, + installedAgentInstallScript, + cachedScriptPath +} = require('./bootstrap-runner.cjs') + +const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' + +function mkTmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-')) +} test('runBootstrap bails immediately when the signal is already aborted', async () => { const controller = new AbortController() @@ -25,3 +39,100 @@ test('runBootstrap bails immediately when the signal is already aborted', async 'should emit a cancelled failure event' ) }) + +test('installedAgentInstallScript resolves the installer in the agent checkout', () => { + const home = mkTmpHome() + try { + assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists') + + const scriptsDir = path.join(home, 'hermes-agent', 'scripts') + fs.mkdirSync(scriptsDir, { recursive: true }) + const scriptPath = path.join(scriptsDir, SCRIPT_NAME) + fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n') + + assert.equal(installedAgentInstallScript(home), scriptPath) + assert.equal(installedAgentInstallScript(null), null, 'null home -> null') + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + +test('resolveInstallScript prefers a cached script without touching the network', async () => { + const home = mkTmpHome() + try { + const commit = 'a'.repeat(40) + const cached = cachedScriptPath(home, commit) + fs.mkdirSync(path.dirname(cached), { recursive: true }) + fs.writeFileSync(cached, '#!/bin/sh\necho cached\n') + + const logs = [] + const result = await resolveInstallScript({ + installStamp: { commit }, + sourceRepoRoot: null, + hermesHome: home, + emit: ev => logs.push(ev) + }) + + assert.equal(result.source, 'cache') + assert.equal(result.path, cached) + } 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 { + const commit = 'a'.repeat(40) + // Seed the installed agent checkout so the fallback has something to resolve. + const scriptsDir = path.join(home, 'hermes-agent', 'scripts') + fs.mkdirSync(scriptsDir, { recursive: true }) + const installed = path.join(scriptsDir, SCRIPT_NAME) + fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n') + + const logs = [] + const result = await resolveInstallScript({ + installStamp: { commit }, + sourceRepoRoot: null, + hermesHome: home, + emit: ev => logs.push(ev), + // Simulate GitHub returning a 404 for the pinned commit. + _download: async () => { + throw new Error('Failed to download install.sh: HTTP 404') + } + }) + + assert.equal(result.source, 'installed-agent') + // It should have copied the installer into the bootstrap cache. + assert.equal(result.path, cachedScriptPath(home, commit)) + assert.ok(fs.existsSync(result.path), 'fallback script copied into cache') + assert.ok( + logs.some(ev => /falling back to installed agent/.test(ev.line || '')), + 'emits a fallback log line' + ) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + +test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => { + const home = mkTmpHome() + try { + const commit = 'a'.repeat(40) + // No installed agent checkout seeded -> nothing to fall back to. + await assert.rejects( + resolveInstallScript({ + installStamp: { commit }, + sourceRepoRoot: null, + hermesHome: home, + emit: () => {}, + _download: async () => { + throw new Error('Failed to download install.sh: HTTP 404') + } + }), + /HTTP 404|Failed to download/ + ) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +})