Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/desktop/electron/bootstrap-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
installerRepoEnv,
installRefForStamp,
installRepositoryForStamp,
isPinnedCommit,
resolveInstallScript,
resolveMarkerPinnedCommit,
Expand Down Expand Up @@ -117,7 +119,8 @@ test('fallback install stamps use an unpinned branch ref', () => {
assert.deepEqual(installRefForStamp(stamp), {
ref: 'main',
cacheKey: 'fallback-main',
pinned: false
pinned: false,
repository: 'NousResearch/hermes-agent'
})
// Must NOT pass -Commit / --commit for the all-zero placeholder.
assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main'])
Expand Down Expand Up @@ -272,3 +275,64 @@ test('resolveInstallScript rethrows when the 404 fallback is unavailable', async
fs.rmSync(home, { recursive: true, force: true })
}
})

test('install refs default to the canonical repository', () => {
assert.equal(installRepositoryForStamp(null), 'NousResearch/hermes-agent')
assert.equal(installRepositoryForStamp({ commit: 'a'.repeat(40) }), 'NousResearch/hermes-agent')
// Anything that isn't a clean owner/name slug must not reach a fetch URL.
assert.equal(installRepositoryForStamp({ repository: 'ForkOwner/hermes-agent/../evil' }), 'NousResearch/hermes-agent')
assert.equal(installRepositoryForStamp({ repository: 'https://evil.example/x/y' }), 'NousResearch/hermes-agent')
// Canonical builds pass no installer env, so install.sh/ps1 keep their defaults.
assert.deepEqual(installerRepoEnv(null), {})
assert.deepEqual(installerRepoEnv({ repository: 'NousResearch/hermes-agent' }), {})
})

test('fork-stamped builds resolve refs and installer env against the fork', () => {
const commit = 'a'.repeat(40)

assert.equal(installRepositoryForStamp({ repository: 'ForkOwner/hermes-agent' }), 'ForkOwner/hermes-agent')
assert.deepEqual(installRefForStamp({ commit, repository: 'ForkOwner/hermes-agent' }), {
ref: commit,
cacheKey: `ForkOwner_hermes-agent-${commit}`,
pinned: true,
repository: 'ForkOwner/hermes-agent'
})
// Unpinned fork stamps must not share a cache entry with the canonical repo.
assert.deepEqual(installRefForStamp({ commit: ZERO_COMMIT, branch: 'main', repository: 'ForkOwner/hermes-agent' }), {
ref: 'main',
cacheKey: 'ForkOwner_hermes-agent-fallback-main',
pinned: false,
repository: 'ForkOwner/hermes-agent'
})
assert.deepEqual(installerRepoEnv({ repository: 'ForkOwner/hermes-agent' }), {
HERMES_INSTALL_REPO_ARCHIVE_BASE: 'https://github.com/ForkOwner/hermes-agent',
HERMES_INSTALL_REPO_URL_HTTPS: 'https://github.com/ForkOwner/hermes-agent.git',
HERMES_INSTALL_REPO_URL_SSH: 'git@github.com:ForkOwner/hermes-agent.git'
})
})

test('resolveInstallScript downloads from the stamped fork repository', async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fork-'))
const seen: any[] = []

try {
const result: any = await resolveInstallScript({
installStamp: { commit: 'b'.repeat(40), branch: 'main', repository: 'ForkOwner/hermes-agent' },
sourceRepoRoot: null,
hermesHome: home,
emit: () => {},
_download: async (ref, destPath, repository) => {
seen.push({ ref, repository })
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, '# installer\n')

return destPath
}
})

assert.equal(result.source, 'download')
assert.deepEqual(seen, [{ ref: 'b'.repeat(40), repository: 'ForkOwner/hermes-agent' }])
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
82 changes: 69 additions & 13 deletions apps/desktop/electron/bootstrap-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,51 @@ const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
const FALLBACK_COMMIT_RE = /^0{7,40}$/
const FALLBACK_BRANCH = 'main'

// Canonical upstream repository. A desktop app built from a fork stamps its own
// `owner/name` so first-launch bootstrap fetches install.sh/ps1 -- and clones
// the agent checkout -- from the repo the app was actually built from.
const DEFAULT_INSTALL_REPOSITORY = 'NousResearch/hermes-agent'
const INSTALL_REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/

function isPinnedCommit(commit) {
return typeof commit === 'string' && STAMP_COMMIT_RE.test(commit) && !FALLBACK_COMMIT_RE.test(commit)
}

/**
* The GitHub `owner/name` an install stamp points at, defaulting to the
* canonical repo so official builds and pre-`repository` stamps are unchanged.
* Anything that isn't a plain, well-formed slug is rejected rather than
* interpolated into a URL.
*/
function installRepositoryForStamp(installStamp) {
const value = installStamp && installStamp.repository

if (typeof value === 'string' && INSTALL_REPOSITORY_RE.test(value.trim())) {
return value.trim()
}

return DEFAULT_INSTALL_REPOSITORY
}

/**
* Environment handed to install.sh / install.ps1 so the installer clones the
* same repository the desktop app was built from. Empty for canonical builds --
* the scripts keep their own hardcoded defaults.
*/
function installerRepoEnv(installStamp) {
const repository = installRepositoryForStamp(installStamp)

if (repository === DEFAULT_INSTALL_REPOSITORY) {
return {}
}

return {
HERMES_INSTALL_REPO_ARCHIVE_BASE: `https://github.com/${repository}`,
HERMES_INSTALL_REPO_URL_HTTPS: `https://github.com/${repository}.git`,
HERMES_INSTALL_REPO_URL_SSH: `git@github.com:${repository}.git`
}
}

type ExecGitFn = (args: string[], cwd: string) => string
type ResolveHeadFn = (activeRoot: string | null | undefined) => string | null

Expand Down Expand Up @@ -131,11 +172,18 @@ function resolveMarkerPinnedCommit(
* never asks GitHub for commit 0000000... (#50823).
*/
function installRefForStamp(installStamp) {
const repository = installRepositoryForStamp(installStamp)
// Fork refs live in a different namespace than canonical ones, so keep their
// cached scripts separate (an unpinned `fallback-main` key would otherwise
// collide across repositories).
const scope = repository === DEFAULT_INSTALL_REPOSITORY ? '' : `${repository.replace(/[^0-9A-Za-z._-]/g, '_')}-`

if (installStamp && isPinnedCommit(installStamp.commit)) {
return {
ref: installStamp.commit,
cacheKey: installStamp.commit,
pinned: true
cacheKey: `${scope}${installStamp.commit}`,
pinned: true,
repository
}
}

Expand All @@ -144,8 +192,9 @@ function installRefForStamp(installStamp) {

return {
ref,
cacheKey: `fallback-${String(ref).replace(/[^0-9A-Za-z._-]/g, '_')}`,
pinned: false
cacheKey: `${scope}fallback-${String(ref).replace(/[^0-9A-Za-z._-]/g, '_')}`,
pinned: false,
repository
}
}

Expand Down Expand Up @@ -227,13 +276,14 @@ function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
}

function downloadInstallScript(ref, destPath) {
function downloadInstallScript(ref, destPath, repository = DEFAULT_INSTALL_REPOSITORY) {
// Fetch from GitHub raw at the install ref. Normal production builds pass a
// pinned SHA (immutable). 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.
// placeholder is a real GitHub commit. Fork-built apps pass their own
// repository so the ref resolves in the repo it actually came from.
const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${ref}/scripts/${scriptName}`
const url = `https://raw.githubusercontent.com/${repository}/${ref}/scripts/${scriptName}`

return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
Expand Down Expand Up @@ -362,12 +412,12 @@ async function resolveInstallScript({
emit({
type: 'log',
line:
`[bootstrap] fetching ${installScriptName()} for ${installRef.ref.slice(0, 12)} from GitHub` +
`[bootstrap] fetching ${installScriptName()} for ${installRef.ref.slice(0, 12)} from ${installRef.repository}` +
(installRef.pinned ? '' : ' (fallback, unpinned)')
})

try {
await _download(installRef.ref, cached)
await _download(installRef.ref, cached, installRef.repository)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })

return { path: cached, source: 'download', commit: resolvedCommit, kind: installScriptKind() }
Expand Down Expand Up @@ -456,7 +506,7 @@ function resolveWindowsPowerShell() {
return 'powershell.exe'
}

function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome, installerEnv }: any = {}) {
return new Promise<any>((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
Expand All @@ -468,6 +518,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
...(installerEnv || {}),
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
Expand Down Expand Up @@ -560,12 +611,13 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
})
}

function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome, installerEnv }: any = {}) {
return new Promise<any>((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
...(installerEnv || {}),
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
Expand Down Expand Up @@ -700,7 +752,8 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: '__manifest__',
hermesHome
hermesHome,
installerEnv: installerRepoEnv(installStamp)
})

if (result.code !== 0) {
Expand Down Expand Up @@ -782,7 +835,8 @@ async function runStage({
emit,
stageName: stage.name,
abortSignal,
hermesHome
hermesHome,
installerEnv: installerRepoEnv(installStamp)
})

const durationMs = Date.now() - startedAt
Expand Down Expand Up @@ -1025,7 +1079,9 @@ export {
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
installerRepoEnv,
installRefForStamp,
installRepositoryForStamp,
isPinnedCommit,
// Exposed for testability
parseStageResult,
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// build hasn't been invoked, or schema mismatch). Callers must handle null.
//
// Schema:
// { schemaVersion: 1, commit, branch, builtAt, dirty, source }
// { schemaVersion: 1, commit, branch, repository, builtAt, dirty, source }
const INSTALL_STAMP_SCHEMA_VERSION = 1

function loadInstallStamp() {
Expand Down Expand Up @@ -479,6 +479,11 @@ function loadInstallStamp() {
schemaVersion: parsed.schemaVersion,
commit: parsed.commit,
branch: parsed.branch || null,
// GitHub owner/name this build came from; bootstrap fetches the
// installer (and clones the agent) from here so fork-built apps do
// not chase refs that only exist in their own repo. Absent on
// pre-repository stamps -- bootstrap defaults to the canonical repo.
repository: parsed.repository || null,
builtAt: parsed.builtAt || null,
dirty: Boolean(parsed.dirty),
source: parsed.source || null,
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/scripts/write-build-stamp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* "schemaVersion": 1,
* "commit": "<40-char SHA>",
* "branch": "<branch name>",
* "repository": "<owner/name>", // GitHub repo the build came from
* "builtAt": "<ISO 8601 UTC timestamp>",
* "dirty": true|false,
* "source": "ci" | "local" | "fallback"
Expand Down Expand Up @@ -37,6 +38,27 @@ const STAMP_SCHEMA_VERSION = 1
/** All-zero placeholder used when no real commit can be resolved. */
export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000"
export const FALLBACK_BRANCH = "main"
/** Canonical upstream repo; forks stamp their own so bootstrap follows them. */
export const DEFAULT_REPOSITORY = "NousResearch/hermes-agent"

const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/

/**
* Reduce a GitHub remote URL (or an already-plain slug) to `owner/name`.
* Returns null for anything that isn't GitHub, so non-GitHub remotes fall back
* to the canonical repository rather than producing a bogus fetch URL.
*/
export function normalizeGitHubRepository(value) {
if (typeof value !== "string") return null
const trimmed = value.trim().replace(/\/+$/, "")
if (!trimmed) return null

const ssh = trimmed.match(/^(?:ssh:\/\/)?git@github\.com[:/](.+?)(?:\.git)?$/i)
const https = trimmed.match(/^https?:\/\/(?:[^@/]+@)?github\.com\/(.+?)(?:\.git)?$/i)
const slug = ssh?.[1] ?? https?.[1] ?? (trimmed.includes("://") || trimmed.includes("@") ? null : trimmed)

return typeof slug === "string" && REPOSITORY_RE.test(slug) ? slug : null
}

const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
Expand All @@ -58,6 +80,7 @@ export function fromCI(env = process.env) {
return {
commit: sha,
branch: branch,
repository: normalizeGitHubRepository(env.GITHUB_REPOSITORY),
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
}
Expand All @@ -75,9 +98,13 @@ export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
// differs from the commit being pinned.
const status = execFn("git status --porcelain -uno", { cwd: repoRoot })
const dirty = status !== null && status.length > 0
// A fork-built app must bootstrap from the fork: its branches and commits do
// not exist upstream. `origin` is the remote the checkout was cloned from.
const remote = execFn("git remote get-url origin", { cwd: repoRoot })
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
repository: normalizeGitHubRepository(remote),
dirty: dirty,
source: "local"
}
Expand All @@ -92,6 +119,7 @@ export function fromFallback(branch = FALLBACK_BRANCH) {
return {
commit: FALLBACK_COMMIT,
branch: branch || FALLBACK_BRANCH,
repository: null,
dirty: false,
source: "fallback"
}
Expand Down Expand Up @@ -153,6 +181,7 @@ function main() {
schemaVersion: STAMP_SCHEMA_VERSION,
commit: stamp.commit,
branch: stamp.branch,
repository: stamp.repository || DEFAULT_REPOSITORY,
builtAt: new Date().toISOString(),
dirty: stamp.dirty,
source: stamp.source
Expand Down
Loading
Loading