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 }) + } +})