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:
@@ -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"`))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", "")])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user