fix(bundle): the agent's terminal runs a working hermes inside the desktop payload (#129643)

Inside a packaged desktop build, bare `hermes` in the terminal tool resolved
to <payload>/venv/Scripts/hermes.exe. That venv was built on CI, and its
Windows redirector reads pyvenv.cfg `home`, which still names the CI path,
so every call failed with "did not find executable at C:\a\hermes-agent\...".
The working launcher in <payload>/bin carries the channel name
(hermes-onboarding-v1-preview.exe) and was never on the agent's PATH.

- activate_dependencies puts the payload's own launcher dir (from the
  manifest's runtime.commands) first on PATH, and on Windows no longer adds
  the shipped venv's Scripts dir at all.
- _resolve_hermes_bin_dir answers with that launcher dir in a sealed
  payload, so terminal and cron children do not depend on PATH order.
- stageDesktopLaunchers copies the canonical launchers to their qualified
  names instead of renaming them. The canonical `hermes` stays private in
  <payload>/bin; only the qualified names are published as MSIX aliases and
  ~/.local/bin links.
- The "plugin already exists" hint names the real CLI command.

Source and dev installs are unchanged: they have no payload manifest.
This commit is contained in:
Siddharth Balyan
2026-10-01 02:25:42 +05:30
committed by GitHub
parent 70d02a1017
commit 396276f0f7
5 changed files with 75 additions and 26 deletions

View File

@@ -51,8 +51,10 @@ test('desktop payload exposes only identity-qualified launchers while preserving
assert.equal(fs.readFileSync(path.join(payload, file), 'utf8'), `PE bytes for ${name}`)
assert.ok(file.includes(cliName))
}
assert.equal(fs.existsSync(path.join(payload, 'bin/hermes.exe')), false)
assert.equal(fs.existsSync(path.join(payload, 'bin/hermes-acp.exe')), false)
// The agent's terminal runs the canonical names; they are never published as aliases.
for (const [name, file] of Object.entries(commands)) {
assert.equal(fs.readFileSync(path.join(payload, file), 'utf8'), `PE bytes for ${name}`)
}
assert.deepEqual(stamps.stageDesktopLaunchers(payload, { cliName }), manifest)
const xml = appExecutionAliasExtensions(manifest.launchers)
assert.ok(xml.includes(`Alias="${cliName}.exe"`))

View File

@@ -6,7 +6,7 @@
* Provenance comes from CI, local git, or an explicit unknown-source stamp.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { resolve, join, relative, posix } from "path"
import productIdentity from "../product-identity.cjs"
import { channelBuildRequest } from "../../../scripts/msix-shared.mjs"
@@ -115,7 +115,10 @@ export function isFallbackCommit(commit) {
/** Qualify desktop CLI files before the immutable runtime paths are baked.
* Canonical command keys remain stable for backend consumers; public aliases
* come from the declared filenames, never those internal keys.
* come from the declared filenames, never those internal keys. The canonical
* files stay beside them: the agent's own terminal puts <payload>/bin first on
* PATH (pm.environments.activate_dependencies) and runs bare `hermes`, while
* only the qualified names are published (MSIX aliases, ~/.local/bin links).
*/
export function stageDesktopLaunchers(root, identity = productIdentity) {
const file = join(root, 'manifest.json')
@@ -127,7 +130,7 @@ export function stageDesktopLaunchers(root, identity = productIdentity) {
const alias = name.replace(/^hermes(?=-|$)/, identity.cliName)
const destination = posix.join(posix.dirname(source), `${alias}${windows ? '.exe' : ''}`)
if (source !== destination && existsSync(join(root, source))) {
renameSync(join(root, source), join(root, destination))
copyFileSync(join(root, source), join(root, destination))
}
if (!existsSync(join(root, destination))) throw new Error(`Missing desktop launcher: ${destination}`)
commands[name] = destination

View File

@@ -352,9 +352,12 @@ def _install_plugin_core(
_refuse_unavailable_portable_plugin(plugin_name, tmp_target)
if target.exists() and not force:
from pm.environments import cli_command_name
from pm.paths import repo_root
raise _pc().PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`.")
f"or run `{cli_command_name(repo_root())} plugins update {plugin_name}`.")
prior = old_metadata.get(plugin_name)
if target.exists() and requested_revision is None and isinstance(prior, dict) and prior.get("pinned") is True:
raise _pc().PluginOperationError(

View File

@@ -91,20 +91,50 @@ def record_activation_inputs(stamps: Path, mtimes: dict[str, int], project_root:
os.utime(stamp, ns=(mtime, mtime))
def payload_venv(project_root: Path) -> Path | None:
"""The environment a sealed payload ships beside its tree, or ``None``."""
root = Path(project_root).resolve()
def _payload_manifest(root: Path) -> dict | None:
"""The manifest of the sealed payload whose tree is the resolved *root*, or ``None``."""
manifest_path = root.parent / "manifest.json"
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
if (root.parent / manifest.get("repo", "")).resolve() == root:
venv = (root.parent / manifest["venv"]).resolve()
if not venv.is_relative_to(root.parent):
raise RuntimeError("payload environment escapes its root")
return venv
return manifest
return None
def payload_venv(project_root: Path) -> Path | None:
"""The environment a sealed payload ships beside its tree, or ``None``."""
root = Path(project_root).resolve()
manifest = _payload_manifest(root)
if manifest is None:
return None
venv = (root.parent / manifest["venv"]).resolve()
if not venv.is_relative_to(root.parent):
raise RuntimeError("payload environment escapes its root")
return venv
def _payload_hermes_command(root: Path) -> Path | None:
manifest = _payload_manifest(root)
command = manifest.get("runtime", {}).get("commands", {}).get("hermes") if manifest else None
return root.parent / command if command else None
def payload_command_dir(project_root: Path) -> Path | None:
"""The directory holding a sealed payload's own launchers (``<payload>/bin``), or ``None``."""
command = _payload_hermes_command(Path(project_root).resolve())
return command.parent if command else None
def cli_command_name(project_root: Path) -> str:
"""The name a person types to run this install's CLI.
A desktop channel build publishes its launcher under a qualified name
(``hermes-canary``) so it never shadows another install's ``hermes``.
"""
command = _payload_hermes_command(Path(project_root).resolve())
return command.name.removesuffix(".exe") if command else "hermes"
def base_venv(project_root: Path) -> Path:
return payload_venv(project_root) or project_venv_dir(Path(project_root).resolve()) or Path(project_root).resolve() / "venv"
@@ -115,14 +145,12 @@ def store_root(project_root: Path) -> Path:
if override:
return Path(override).resolve()
root = Path(project_root).resolve()
manifest_path = root.parent / "manifest.json"
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
if (root.parent / manifest.get("repo", "")).resolve() == root:
store = (root.parent / manifest["store"]).resolve()
if not store.is_relative_to(root.parent):
raise RuntimeError("payload store escapes its root")
return store
manifest = _payload_manifest(root)
if manifest is not None:
store = (root.parent / manifest["store"]).resolve()
if not store.is_relative_to(root.parent):
raise RuntimeError("payload store escapes its root")
return store
from pm.paths import install_stamp_path
for directory in (root, *root.parents):
@@ -353,12 +381,16 @@ def activate_dependencies(project_root: Path) -> None:
*[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]]
os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)])
os.environ.pop("VIRTUAL_ENV", None)
executable_dir = venv_bin_dir(environment)
# The venv's own `hermes`/`hermes-acp` console scripts are editable installs bound to
# the build-time source snapshot, not this checkout (#124627): a child that resolves
# `hermes` off PATH must hit the checkout's own launcher first, never the venv's copy.
prefix = [str(path) for path in (project_root.resolve() / ".hermes" / "bin", executable_dir)
if path.is_dir()]
# A sealed payload's launchers live in <payload>/bin instead.
directories = [payload_command_dir(project_root) or project_root.resolve() / ".hermes" / "bin"]
# A shipped venv's Windows redirectors read pyvenv.cfg `home`, which still names the
# build machine, so every executable in its Scripts fails on the user's machine.
if not (os.name == "nt" and environment == payload_venv(project_root)):
directories.append(venv_bin_dir(environment))
prefix = [str(path) for path in directories if path.is_dir()]
if prefix:
os.environ["PATH"] = os.pathsep.join([*prefix, os.environ.get("PATH", "")])

View File

@@ -601,11 +601,20 @@ _HERMES_BIN_DIR: "str | None | object" = _SENTINEL
def _resolve_hermes_bin_dir() -> str | None:
"""Directory holding the ``hermes`` console-script, or None (cached). A gateway
launched by systemd/cron/a desktop launcher lacks the install dir on PATH and bare
``hermes`` exits 127. Order: ``which``; absolute ``sys.argv[0]`` naming a real
hermes executable; ``sys.executable``'s dir if it holds the shim."""
``hermes`` exits 127. Order: a sealed payload's own launcher dir; ``which``; absolute
``sys.argv[0]`` naming a real hermes executable; ``sys.executable``'s dir if it holds
the shim."""
global _HERMES_BIN_DIR
if _HERMES_BIN_DIR is not _SENTINEL:
return _HERMES_BIN_DIR # type: ignore[return-value]
from pm.environments import payload_command_dir
# A payload's venv also holds a `hermes`, but on Windows its redirector names the
# build machine's interpreter, so PATH order must not decide which copy children get.
payload_dir = payload_command_dir(Path(__file__).resolve().parents[2])
if payload_dir is not None and payload_dir.is_dir():
_HERMES_BIN_DIR = str(payload_dir)
return _HERMES_BIN_DIR
which = shutil.which("hermes")
argv0 = sys.argv[0] if sys.argv else ""
base = os.path.basename(argv0).lower()