Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0 on source installs, rewritten by release stamping) leaked v0.0.0 into About, /api/health, User-Agents, and plugin compat, and source updates showed "couldn't reach update server" because identity and channel authority disagreed with the checkout. Now: get_version_info() resolves install stamp -> live git -> unknown, never pyproject metadata, never a package constant. Source checkouts derive identity from their reachable release tag; the completion tail of every successful install/update/historical takeover atomically rewrites install-stamp.json with that identity; a stale source stamp whose commit no longer matches HEAD defers to live git. ACP/TUI use derived_version for display and base_version for protocol fields; all ~44 runtime __version__ consumers migrated; hermes_cli.__version__ and generated _version.py are gone; release stamping only touches the native manifests external builders consume (nix/tauri/cargo) and passes release identity straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0. Desktop no longer synthesizes a competing install-stamp.json: the checkout owns its stamp, and desktop-bootstrap classification keys on the bootstrap-complete marker. verify-bootstrap-version-stamp.py now cross-checks the checkout's stamp (baseVersion + commit == HEAD). Validation: 31-file focused suite green (version identity, stamping, adoption, providers, gateway, acp/tui runtime identity, api server via extras env, release graph); desktop tsc + 25 vitest green; real-repo probe: base=unknown derived=git.0635606.dirty source=git on this checkout; clean-env imports resolve entirely from this tree; windows footgun + compat-pointer scans clean.
177 lines
10 KiB
Python
177 lines
10 KiB
Python
"""Source E2E children stamp their own checkout, not the workflow's NEW ref."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
ASSETS = ROOT / "tests/install/e2e-assets"
|
|
|
|
|
|
def _stamp_probe(tmp_path, shell):
|
|
repo = tmp_path / "installed source"
|
|
for relative in (
|
|
"scripts/write_install_stamp.py", "hermes_cli/__init__.py",
|
|
"scripts/releases/distance.py", "scripts/releases/versioning.py",
|
|
"hermes_cli/update_channel.py", "hermes_cli/release_channels.py",
|
|
"pm/paths.py", "pm/environments.py",
|
|
"hermes_cli/steward.py", "hermes_constants.py",
|
|
):
|
|
dest = repo / relative
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(ROOT / relative, dest)
|
|
env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(tmp_path / "home"),
|
|
GIT_CONFIG_GLOBAL=str(tmp_path / "gitconfig"), GIT_CONFIG_NOSYSTEM="1")
|
|
(tmp_path / "gitconfig").write_text('[url "file:///staged/serve.git"]\n'
|
|
'\tinsteadOf = https://github.com/NousResearch/hermes-agent.git\n',
|
|
encoding="utf-8")
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], cwd=repo, env=env, check=True,
|
|
capture_output=True, text=True, timeout=30).stdout.strip()
|
|
|
|
git("init", "-q", "-b", "installed")
|
|
git("config", "user.name", "Fixture")
|
|
git("config", "user.email", "fixture@example.invalid")
|
|
git("add", ".")
|
|
git("commit", "-qm", "OLD")
|
|
old = git("rev-parse", "HEAD")
|
|
git("tag", "v1.0.0", old)
|
|
git("commit", "--allow-empty", "-qm", "NEW")
|
|
new = git("rev-parse", "HEAD")
|
|
# Control: this is the real stamp writer's CI preference, not a source-text assertion.
|
|
stamp_command = [sys.executable, "-I", "-S", str(repo / "scripts/write_install_stamp.py"),
|
|
"--output", str(tmp_path / "contaminated.json"),
|
|
"--base-version", "1.0.0", "--distance", "0",
|
|
"--update-mechanism", "self"]
|
|
git("checkout", "-q", "-B", "installed", old)
|
|
subprocess.run(stamp_command, env={**env, "GITHUB_SHA": new}, check=True,
|
|
capture_output=True, text=True, timeout=30)
|
|
assert json.loads((tmp_path / "contaminated.json").read_text(encoding="utf-8-sig"))["commit"] == new
|
|
overrides = dict(GITHUB_SHA=new, GITHUB_REF="refs/heads/workflow", GITHUB_REF_NAME="workflow",
|
|
GITHUB_HEAD_REF="workflow", GITHUB_BASE_REF="main", HERMES_BUILD_COMMIT=new,
|
|
HERMES_PAYLOAD_TAG="v1.2.3", HERMES_PAYLOAD_VERSION="1.2.3",
|
|
HERMES_DESKTOP_VARIANT="bundled")
|
|
env.update(overrides, ASSETS=str(ASSETS), PROBE_PYTHON=sys.executable,
|
|
PROBE_ROOT=str(repo), PROBE_OUT=str(tmp_path / "stamp.json"),
|
|
PROBE_ENV=str(tmp_path / "child-env.json"))
|
|
probe = tmp_path / "probe.py"
|
|
probe.write_text('''import json, os, runpy
|
|
from pathlib import Path
|
|
stamp = runpy.run_path(str(Path(os.environ['PROBE_ROOT']) / 'scripts/write_install_stamp.py'))
|
|
stamp['write_stamp'](os.environ['PROBE_OUT'], update_mechanism='self', base_version='1.0.0', distance=0)
|
|
Path(os.environ['PROBE_ENV']).write_text(json.dumps(dict(os.environ)), encoding='utf-8')
|
|
''', encoding="utf-8")
|
|
env["PROBE_SCRIPT"] = str(probe)
|
|
# A file, not `-c 'import json, ...'`: pwsh 7.6 on the Windows lane re-quotes native
|
|
# argv and the venv launcher receives a truncated -c body (`import` → SyntaxError).
|
|
dump = tmp_path / "dump_env.py"
|
|
dump.write_text("import json, os; print(json.dumps(dict(os.environ)))\n", encoding="utf-8")
|
|
env["PROBE_DUMP"] = str(dump)
|
|
if shell == "bash":
|
|
command = [shell, "-euc", '''
|
|
source "$ASSETS/source-build-env.sh"
|
|
source_build_env "$PROBE_PYTHON" -I -S "$PROBE_SCRIPT"
|
|
if source_build_env "$PROBE_PYTHON" -I -S -c 'raise SystemExit(23)'; then exit 99; else test "$?" = 23; fi
|
|
"$PROBE_PYTHON" -I -S -c 'import json, os; print(json.dumps(dict(os.environ)))'
|
|
''']
|
|
elif shell == "node":
|
|
env["HERMES_DESKTOP_USER_DATA_DIR"] = str(tmp_path / "user-data")
|
|
env["HERMES_PYTHON_SRC_ROOT"] = str(repo)
|
|
command = [shell, "--input-type=module", "-e", '''
|
|
import { pathToFileURL } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
const { updateWindowEnvironment } = await import(pathToFileURL(process.env.ASSETS + '/smoke-env.mjs'));
|
|
const env = updateWindowEnvironment(process.env, process.env.PROBE_ROOT, 'source');
|
|
const result = spawnSync(process.env.PROBE_PYTHON, ['-I', '-S', process.env.PROBE_SCRIPT], { env, stdio: 'inherit' });
|
|
if (result.error || result.status !== 0) throw new Error(`stamp child failed: ${result.error || result.status}`);
|
|
console.log(JSON.stringify(process.env));
|
|
''']
|
|
else:
|
|
# -File, not -Command: Windows PowerShell 5.1 reads a multi-line -Command argument as
|
|
# far as the first line break and exits 0 having run only the preference line.
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text('''$ErrorActionPreference = 'Stop'
|
|
[Console]::Error.WriteLine("probe start: $($PSVersionTable.PSVersion) assets=$env:ASSETS python=$env:PROBE_PYTHON")
|
|
trap { [Console]::Error.WriteLine("probe trap: $_"); [Console]::Error.WriteLine($_.ScriptStackTrace); exit 97 }
|
|
. (Join-Path $env:ASSETS 'source-build-env.ps1')
|
|
if ($env:OS -eq 'Windows_NT') {
|
|
# Diagnostics (stderr, shown only on failure): `&` on the lane returns at once with $LASTEXITCODE
|
|
# unset and no output — PowerShell's behaviour for a non-console image — while Start-Process
|
|
# gets a real exit code. Ask what image the launcher is and whether a known console exe behaves.
|
|
$subsystem = try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($env:PROBE_PYTHON)
|
|
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
|
|
[BitConverter]::ToUInt16($bytes, $pe + 24 + 68)
|
|
} catch { "unreadable: $_" }
|
|
[Console]::Error.WriteLine("probe: launcher subsystem=$subsystem (2=GUI 3=console) passing=$PSNativeCommandArgumentPassing")
|
|
& "$env:SystemRoot\System32\cmd.exe" /c "exit 7"
|
|
[Console]::Error.WriteLine("probe: control cmd.exe exit=$LASTEXITCODE")
|
|
$home_ = (Get-Content -LiteralPath (Join-Path (Split-Path -Parent (Split-Path -Parent $env:PROBE_PYTHON)) 'pyvenv.cfg') |
|
|
Where-Object { $_ -match '^home\s*=' }) -replace '^home\s*=\s*', ''
|
|
[Console]::Error.WriteLine("probe: pyvenv home=$home_ base exists=$(Test-Path -LiteralPath (Join-Path $home_ 'python.exe'))")
|
|
& (Join-Path $home_ 'python.exe') -I -S -c "import sys; sys.exit(5)"
|
|
[Console]::Error.WriteLine("probe: base interpreter exit=$LASTEXITCODE")
|
|
& $env:PROBE_PYTHON -I -S -c "import sys; sys.exit(6)"
|
|
[Console]::Error.WriteLine("probe: launcher exit=$LASTEXITCODE")
|
|
}
|
|
Invoke-SourceBuild {
|
|
& $env:PROBE_PYTHON -I -S $env:PROBE_SCRIPT
|
|
[Console]::Error.WriteLine("probe: child dollar-question=$? exit=$LASTEXITCODE out-exists=$(Test-Path -LiteralPath $env:PROBE_OUT)")
|
|
if ($LASTEXITCODE) { throw 'stamp failed' }
|
|
}
|
|
try { Invoke-SourceBuild { throw 'child failure' }; throw 'lost exception' }
|
|
catch { if ($_.Exception.Message -ne 'child failure') { throw } }
|
|
& $env:PROBE_PYTHON -I -S $env:PROBE_DUMP
|
|
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
|
''', encoding="utf-8-sig")
|
|
command = [shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)]
|
|
print("powershell probe:", shell, file=sys.stderr)
|
|
for sha in (old, new):
|
|
git("checkout", "-q", "-B", "installed", sha)
|
|
result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, stdin=subprocess.DEVNULL,
|
|
text=True, encoding="utf-8", errors="replace", timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert Path(env["PROBE_OUT"]).is_file(), (
|
|
f"the build child wrote no stamp (shell={command[0]} rc={result.returncode})\n--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}")
|
|
stamp = json.loads(Path(env["PROBE_OUT"]).read_text(encoding="utf-8-sig"))
|
|
assert (stamp["commit"], stamp["branch"], stamp["source"], stamp["payload"]) == (
|
|
sha, "installed", "local", "bootstrap")
|
|
assert (stamp["baseVersion"], stamp["distance"], stamp["displayVersion"]) == (
|
|
"1.0.0", 0, "1.0.0")
|
|
child = json.loads(Path(env["PROBE_ENV"]).read_text(encoding="utf-8-sig"))
|
|
assert not overrides.keys() & child.keys()
|
|
assert child["GIT_CONFIG_GLOBAL"] == env["GIT_CONFIG_GLOBAL"]
|
|
redirect = subprocess.run(["git", "ls-remote", "--get-url",
|
|
"https://github.com/NousResearch/hermes-agent.git"], env=child,
|
|
cwd=tmp_path, check=True, capture_output=True, text=True, timeout=30)
|
|
assert redirect.stdout.strip() == "file:///staged/serve.git"
|
|
assert child["HERMES_HOME"] == env["HERMES_HOME"]
|
|
parent = json.loads(result.stdout.splitlines()[-1])
|
|
assert {key: parent[key] for key in overrides} == overrides
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("shell", ["bash", "node"])
|
|
def test_posix_build_children_stamp_old_and_new_without_changing_parent(tmp_path, shell):
|
|
_stamp_probe(tmp_path, shell)
|
|
|
|
|
|
@pytest.mark.platforms("windows", "posix")
|
|
def test_powershell_build_children_stamp_old_and_new_without_changing_parent(tmp_path):
|
|
path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts)
|
|
# Windows PowerShell 5.1 is what install-e2e-windows-run.yml drives windows-e2e.ps1 (the
|
|
# asset's real consumer) with; pwsh is the only PowerShell a POSIX host offers.
|
|
order = ("powershell", "pwsh") if os.name == "nt" else ("pwsh", "powershell")
|
|
shell = next((found for name in order if (found := shutil.which(name, path=path))), None)
|
|
if not shell:
|
|
if os.name == "nt":
|
|
pytest.fail("native Windows acceptance requires PowerShell")
|
|
pytest.skip("PowerShell is not available on this host")
|
|
_stamp_probe(tmp_path, shell)
|