refactor: make install-stamp.json the single runtime version identity

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.
This commit is contained in:
ethernet committed 2026-09-23 11:41:01 -04:00
1 parent 063560696e
commit c13ea774e6
97 files changed
+884 -772

No files matched your search

@@ -1509,7 +1509,6 @@ jobs:
HERMES_PAYLOAD_TAG: ${{ inputs.tag }}
HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }}
HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }}
HERMES_RELEASE_EPOCH: ${{ needs.validate.outputs.release-epoch }}
# termux_build.sh gates on `gh release view <tag>` (refuse to build
# before the release exists); gh needs GH_TOKEN or it errors out and
# the gate misreads that as "release not found".
+24 -17
View File
@@ -30,12 +30,7 @@ on:
type: string
default: ''
version:
description: "Release version stamped into the isolated image build context."
required: false
type: string
default: ''
release-epoch:
description: "Immutable stable claim tagger epoch."
description: "Release version written directly to the image install stamp."
required: false
type: string
default: ''
@@ -168,17 +163,20 @@ jobs:
# release, so the stamp needs both its ancestry and release tags.
fetch-depth: 0
- name: Stamp release build context
if: needs.mode.outputs.release == 'true'
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_EPOCH: ${{ inputs.release-epoch }}
run: >-
python3 scripts/releases/stamping.py --tree . --version "$RELEASE_VERSION"
--release-epoch "$RELEASE_EPOCH"
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
env:
RELEASE_VERSION: ${{ needs.mode.outputs.release == 'true' && inputs.version || '' }}
run: |
display="$RELEASE_VERSION"
if [ -z "$display" ]; then
display="$(python3 -c 'from pathlib import Path; from scripts.releases.distance import dev_version; print(dev_version(Path.cwd()))')"
fi
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci \
--base-version "$base" --display-version "$display" --distance "$distance"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
@@ -345,9 +343,18 @@ jobs:
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
run: |
display="$(python3 -c 'from pathlib import Path; from scripts.releases.distance import dev_version; print(dev_version(Path.cwd()))')"
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci \
--base-version "$base" --display-version "$display" --distance "$distance"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
+2 -7
View File
@@ -26,11 +26,7 @@ on:
required: false
type: string
default: ''
release-epoch:
description: 'Immutable stable claim tagger epoch.'
required: false
type: string
default: ''
permissions:
contents: read
@@ -145,12 +141,11 @@ jobs:
if: inputs.release == true
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_EPOCH: ${{ inputs.release-epoch }}
run: |
mkdir -p "$RUNNER_TEMP/release-source"
git archive "$GITHUB_SHA" | tar -x -C "$RUNNER_TEMP/release-source"
python3 scripts/releases/stamping.py --tree "$RUNNER_TEMP/release-source" \
--version "$RELEASE_VERSION" --release-epoch "$RELEASE_EPOCH"
--version "$RELEASE_VERSION"
- name: nix flake check
# --print-build-logs: a check that fails then prints the assertion
+1 -13
View File
@@ -25,16 +25,7 @@ on:
required: false
type: boolean
default: false
version:
description: 'Release version stamped into the isolated payload tree.'
required: false
type: string
default: ''
release-epoch:
description: 'Immutable stable claim tagger epoch.'
required: false
type: string
default: ''
ref:
description: 'Git ref to bundle (default: the triggering commit, github.sha)'
required: false
@@ -67,9 +58,6 @@ jobs:
bundle:
name: bundle ${{ matrix.target.label }}
runs-on: ${{ matrix.target.runner }}
env:
HERMES_PAYLOAD_VERSION: ${{ inputs.release == true && inputs.version || '' }}
HERMES_RELEASE_EPOCH: ${{ inputs.release == true && inputs.release-epoch || '' }}
# Leave time for setup, cache saves and smoke tests around the wheel build.
timeout-minutes: 180
strategy:
+8 -41
View File
@@ -72,7 +72,6 @@ jobs:
release-phase: test
tag: ${{ needs.admit.outputs.tag }}
version: ${{ needs.admit.outputs.version }}
release-epoch: ${{ needs.admit.outputs.release-epoch }}
nix:
needs: [admit, ci, docker]
@@ -80,7 +79,6 @@ jobs:
with:
release: true
version: ${{ needs.admit.outputs.version }}
release-epoch: ${{ needs.admit.outputs.release-epoch }}
pm-bundle:
needs: [admit, ci, docker]
@@ -88,8 +86,6 @@ jobs:
with:
release: true
ref: ${{ github.sha }}
version: ${{ needs.admit.outputs.version }}
release-epoch: ${{ needs.admit.outputs.release-epoch }}
termux-checks:
needs: [ci, docker]
@@ -206,22 +202,17 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.admit.outputs.commit }}
- name: Set up locked Python build tooling
uses: ./.github/actions/setup-pm
with:
extras: '["dev"]'
- name: Stamp and verify the Cargo and Tauri release identity
env:
RELEASE_COMMIT: ${{ needs.admit.outputs.commit }}
RELEASE_EPOCH: ${{ needs.admit.outputs.release-epoch }}
RELEASE_VERSION: ${{ needs.admit.outputs.version }}
run: |
set -euo pipefail
tree="$RUNNER_TEMP/bootstrap-release"
mkdir -p "$tree"
git archive "$RELEASE_COMMIT" | tar -x -C "$tree"
python -m scripts.releases.stamping --tree "$tree" \
--version "$RELEASE_VERSION" --release-epoch "$RELEASE_EPOCH"
python3 -m scripts.releases.stamping --tree "$tree" \
--version "$RELEASE_VERSION"
actual="$(cargo metadata --no-deps --format-version 1 \
--manifest-path "$tree/apps/bootstrap-installer/src-tauri/Cargo.toml" \
| jq -r '.packages[] | select(.name == "hermes-bootstrap") | .version')"
@@ -229,35 +220,12 @@ jobs:
echo "::error::Bootstrap Cargo version $actual != $RELEASE_VERSION"
exit 1
}
HERMES_NIX_BUILD=1 uv build --wheel --sdist --out-dir "$RUNNER_TEMP/python-dist" "$tree"
RELEASE_DIST="$RUNNER_TEMP/python-dist" python - <<'PY'
import email.parser
import os
import tarfile
import zipfile
from pathlib import Path
expected = os.environ["RELEASE_VERSION"]
dist = Path(os.environ["RELEASE_DIST"])
wheel, = dist.glob("*.whl")
sdist, = dist.glob("*.tar.gz")
with zipfile.ZipFile(wheel) as archive:
metadata, = (name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
wheel_version = email.parser.BytesParser().parsebytes(archive.read(metadata))["Version"]
with tarfile.open(sdist, "r:gz") as archive:
pkg_info, = (member for member in archive.getmembers()
if member.name.endswith("/PKG-INFO") and member.name.count("/") == 1)
stream = archive.extractfile(pkg_info)
if stream is None:
raise SystemExit("sdist PKG-INFO is unreadable")
sdist_version = email.parser.BytesParser().parse(stream)["Version"]
if wheel_version != expected or sdist_version != expected:
raise SystemExit(
f"Python distribution version mismatch: wheel={wheel_version}, sdist={sdist_version}"
)
if f"-{expected}-" not in wheel.name or not sdist.name.endswith(f"-{expected}.tar.gz"):
raise SystemExit("Python distribution filename differs from the release version")
PY
actual_tauri="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' \
"$tree/apps/bootstrap-installer/src-tauri/tauri.conf.json")"
test "$actual_tauri" = "$RELEASE_VERSION" || {
echo "::error::Bootstrap Tauri version $actual_tauri != $RELEASE_VERSION"
exit 1
}
acceptance:
name: All release acceptance checks pass
@@ -283,7 +251,6 @@ jobs:
release-phase: publish
tag: ${{ needs.admit.outputs.tag }}
version: ${{ needs.admit.outputs.version }}
release-epoch: ${{ needs.admit.outputs.release-epoch }}
publish-bundles:
name: Publish tested bundle artifacts
+3 -6
View File
@@ -13,11 +13,6 @@ from acp_adapter.session import SessionState, _expand_acp_enabled_toolsets
logger = logging.getLogger("acp_adapter.server")
try:
from hermes_cli import __version__ as HERMES_VERSION
except Exception:
HERMES_VERSION = "0.0.0"
def _estimate_tokens(history: list, agent: Any, system_prompt: str | None = None, tools: Any = None) -> int:
"""Rough request-token estimate over history + system prompt + tool schemas."""
@@ -304,4 +299,6 @@ class SlashCommandsMixin:
return f"Queued for the next turn. ({_queue_prompt(state, queued_text)} queued)"
def _cmd_version(self, args: str, state: SessionState) -> str:
return f"Hermes Agent v{HERMES_VERSION}"
from hermes_cli.version_info import get_version_info
return f"Hermes Agent v{get_version_info().derived_version}"
+2 -2
View File
@@ -98,9 +98,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
def _print_version() -> None:
from hermes_cli import __version__ as hermes_version
from hermes_cli.version_info import get_version_info
print(hermes_version)
print(get_version_info().derived_version)
def _run_check() -> None:
+4 -2
View File
@@ -25,7 +25,7 @@ from acp.schema import (
)
from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, detect_provider
from acp_adapter.commands import HERMES_VERSION, SlashCommandsMixin, _estimate_tokens
from acp_adapter.commands import SlashCommandsMixin, _estimate_tokens
from acp_adapter.content import PromptBlock, _content_blocks_to_openai_user_content, _extract_text
from acp_adapter.events import (
AssistantMessageIdAllocator, _build_plan_update_from_todo_result, _send_update, flush_open_tool_calls,
@@ -524,6 +524,8 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
self, protocol_version: int | None = None, client_capabilities: ClientCapabilities | None = None,
client_info: Implementation | None = None, **kwargs: Any,
) -> InitializeResponse:
from hermes_cli.version_info import get_version_info
auth_methods = build_auth_methods()
logger.info(
"Initialize from %s (protocol v%s)", client_info.name if client_info else "unknown",
@@ -532,7 +534,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
return InitializeResponse(
protocol_version=acp.PROTOCOL_VERSION,
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_info=Implementation(name="hermes-agent", version=get_version_info().base_version),
agent_capabilities=AgentCapabilities(
load_session=True,
prompt_capabilities=PromptCapabilities(image=True),
+2 -2
View File
@@ -28,7 +28,7 @@ from agent.anthropic_message_convert import (
convert_messages_to_anthropic, convert_tools_to_anthropic, normalize_model_name,
)
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
# ``import anthropic`` is deliberately NOT at module top: the SDK costs ~220 ms of imports and
@@ -342,7 +342,7 @@ def _attribution_headers() -> Dict[str, str]:
"""Same client-attribution set sent to OpenRouter / Vercel AI Gateway / Fireworks."""
return {
"HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
}
+3 -3
View File
@@ -932,16 +932,16 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict:
# Vercel AI Gateway attribution (HTTP-Referer → referrerUrl, X-Title → appName).
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
_AI_GATEWAY_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
}
# Nous Portal attribution extra_body. Tags come from agent.portal_tags so the client= marker
# tracks hermes_cli.__version__ — never inline a literal here.
# tracks the canonical base version — never inline a literal here.
from agent.portal_tags import nous_portal_tags as _nous_portal_tags
+2 -2
View File
@@ -23,8 +23,8 @@ _NO_SOCKETS_SUFFIX = " — no sockets found; in-flight request may keep running
def _routermint_headers() -> dict:
"""User-Agent RouterMint needs to avoid Cloudflare 1010 blocks."""
from hermes_cli import __version__ as _HERMES_VERSION
return {"User-Agent": f"HermesAgent/{_HERMES_VERSION}"}
from hermes_cli.version_info import get_version_info
return {"User-Agent": f"HermesAgent/{get_version_info().base_version}"}
def _qwen_portal_headers() -> dict:
+5 -2
View File
@@ -40,8 +40,11 @@ def codex_cloudflare_headers(access_token: str, *, base_url: str = CODEX_AUX_BAS
OAuth JWT (see :func:`codex_account_headers`).
"""
if is_official_codex_base_url(base_url):
from hermes_cli import __version__
headers = {"User-Agent": f"HermesAgent/{__version__}", "originator": "hermes-agent"}
from hermes_cli.version_info import get_version_info
headers = {
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
"originator": "hermes-agent",
}
else:
headers = {"User-Agent": "codex_cli_rs/0.0.0 (Hermes Agent)", "originator": "codex_cli_rs"}
headers.update(codex_account_headers(access_token))
+2 -6
View File
@@ -25,13 +25,9 @@ from agent.gemini_schema import prepare_gemini_tool_parameters, sanitize_gemini_
logger = logging.getLogger(__name__)
try:
import hermes_cli as _hermes_cli
from hermes_cli.version_info import get_version_info
_HERMES_VERSION = str(_hermes_cli.__version__)
except Exception:
_HERMES_VERSION = "0.0.0"
_API_CLIENT = f"hermes-agent/{_HERMES_VERSION}" # client context per Gemini's partner-integration guidance
_API_CLIENT = f"hermes-agent/{get_version_info().base_version}" # client context per Gemini's partner-integration guidance
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# A Vertex AI express-mode base, when the user configures one explicitly: aiplatform serves the
+2 -2
View File
@@ -230,8 +230,8 @@ def _safe_profile() -> str:
def _safe_version() -> str:
try:
from hermes_cli import __version__
return str(__version__)
from hermes_cli.version_info import get_version_info
return get_version_info().base_version
except Exception:
return "unknown"
+6 -10
View File
@@ -2,10 +2,9 @@
Every Hermes request to the Nous Portal (main loop, auxiliary client, fallback
paths) must carry the same product-attribution tags, sent in OpenAI-compatible
``extra_body['tags']``: ``["product=hermes-agent", "client=hermes-client-v<__version__>"]``.
The version is read live from ``hermes_cli.__version__`` — do NOT pre-compute it
as a module constant in consumers; it can change at runtime (editable installs,
hot reload).
``extra_body['tags']``: ``["product=hermes-agent", "client=hermes-client-v<base_version>"]``.
The value comes from the canonical runtime identity's base version so build
provenance never leaks into this compatibility tag.
"""
from __future__ import annotations
@@ -72,12 +71,9 @@ def get_conversation_context() -> Optional[str]:
def hermes_client_tag() -> str:
"""``client=hermes-client-v<MAJOR>.<MINOR>.<PATCH>`` ("unknown" if hermes_cli is unimportable)."""
try:
from hermes_cli import __version__
except Exception:
__version__ = "unknown"
return f"client=hermes-client-v{__version__}"
"""``client=hermes-client-v<MAJOR>.<MINOR>.<PATCH>`` from canonical runtime identity."""
from hermes_cli.version_info import get_version_info
return f"client=hermes-client-v{get_version_info().base_version}"
def conversation_tag(session_id: str) -> str:
@@ -132,7 +132,7 @@ test('fallback install stamps use an unpinned branch ref', () => {
)
})
test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', () => {
test('resolveMarkerPinnedCommit prefers installed checkout HEAD over the packaged artifact', () => {
const realHead = 'c'.repeat(40)
assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/checkout', {
@@ -144,8 +144,8 @@ test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', ()
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
resolveHead: () => realHead
}),
'd'.repeat(40),
'packaged real pin wins over checkout HEAD'
realHead,
'the installed checkout owns source runtime identity'
)
assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
+8 -6
View File
@@ -110,8 +110,10 @@ function readExistingPinnedCommit(activeRoot: string | null | undefined): string
/**
* Pick the commit to store on the bootstrap-complete marker.
* Packaged fallback stamps must NOT win (all-zero is not a real pin); after a
* successful install the checkout's HEAD (or install.ps1's marker) does.
* The installed checkout owns source runtime identity: its live HEAD wins, so
* a repair/update bootstrap reports the commit the checkout is actually at,
* never the older commit baked into the packaged app. Packaged fallback stamps
* (all-zero) are not real pins and never win.
*/
function resolveMarkerPinnedCommit(
installStamp: { commit?: string; branch?: string | null } | null | undefined,
@@ -120,16 +122,16 @@ function resolveMarkerPinnedCommit(
): string | null {
const resolveHead = opts.resolveHead || resolveCheckoutHead
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
const head = resolveHead(activeRoot)
if (head) {
return head
}
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
return readExistingPinnedCommit(activeRoot)
}
+15 -36
View File
@@ -4194,9 +4194,9 @@ function activeRuntimeState(backend: SourceBackend | null): ActiveRuntimeState {
return state
}
/** Read the install stamp the bootstrap wrote into the canonical runtime
* root (`ACTIVE_HERMES_ROOT/install-stamp.json`). Returns null when the
* runtime wasn't desktop-bootstrapped (or the file is unreadable). */
/** Read the checkout-owned install stamp in the canonical runtime root
* (`ACTIVE_HERMES_ROOT/install-stamp.json`), written by the Python
* completion tail. Returns null when absent or unreadable. */
function readCanonicalInstallStamp() {
try {
const raw = fs.readFileSync(path.join(ACTIVE_HERMES_ROOT, 'install-stamp.json'), 'utf8')
@@ -4225,35 +4225,11 @@ function writeBootstrapMarker(payload) {
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
// The canonical runtime this bootstrap created is a desktop-managed install
// — write its own install stamp alongside it (the same schema the packagers
// produce, so every surface reads provenance the same way). Unlike the
// artifact stamp this one records where the install CAME from
// (desktop-bootstrap) plus the commit the checkout was pinned to.
try {
const canonicalStamp = {
schemaVersion: 1,
commit: payload.pinnedCommit || null,
branch: payload.pinnedBranch || null,
builtAt: new Date().toISOString(),
dirty: false,
source: 'desktop-bootstrap',
distribution: null,
updateMechanism: 'self',
baseVersion: null,
distance: null,
payload: 'bootstrap',
tag: null
}
const canonicalStampPath = path.join(ACTIVE_HERMES_ROOT, 'install-stamp.json')
writeFileAtomic(canonicalStampPath, JSON.stringify(canonicalStamp, null, 2) + '\n', 'utf8')
rememberLog(`[bootstrap] wrote canonical install stamp to ${canonicalStampPath}`)
} catch (error) {
// The marker is the hard contract; a failed stamp write is log-worthy but
// must never fail the bootstrap (the runtime itself is already installed).
rememberLog(`[bootstrap] failed to write canonical install stamp: ${error?.message || error}`)
}
// The checkout's own install stamp is written by the Python completion tail
// (hermes_cli/source_completion.py) during the products stage, from the
// checkout itself. The desktop never synthesizes it: the checkout is the
// authority for its runtime identity, and a desktop-written copy would
// clobber the completion tail's richer provenance.
return merged
}
@@ -17388,9 +17364,9 @@ export function isInstallerCreatedCheckout(root: string | null = ACTIVE_HERMES_R
/** Classify what this build carries (embedded / light / external). The stamp's
* `payload` decides the first two; an external build classifies its root via
* the canonical-root install stamp (desktop-bootstrapped) or the app stamp's
* `source`, so About's Runtime row names git/docker/nix/desktop-bootstrap
* instead of a bare "external". */
* the canonical-root checkout stamp plus the bootstrap marker, or the app
* stamp's `source`, so About's Runtime row names
* git/docker/nix/desktop-bootstrap instead of a bare "external". */
function resolveHermesRuntime() {
const stamp = INSTALL_STAMP as InstallStamp | null
@@ -17405,7 +17381,10 @@ function resolveHermesRuntime() {
const root = resolveUpdateRoot()
const canonicalStamp = readCanonicalInstallStamp()
if (canonicalStamp?.source === 'desktop-bootstrap') {
// A desktop first-launch bootstrap is attested by the bootstrap-complete
// marker, not by the stamp: the stamp is the checkout's own identity
// (source: git, written by the Python completion tail).
if (canonicalStamp?.updateMechanism === 'self' && readBootstrapMarker()) {
return { type: 'desktop-bootstrap', root }
}
+3 -10
View File
@@ -188,16 +188,9 @@ async def _call_verifier(verifier, *args, **kwargs):
def _hermes_version() -> str:
"""Canonical Hermes version: ``hermes_cli.__version__`` (dist-info can be stale on
source checkouts), then distribution metadata, then "dev". Never raises."""
with suppress(Exception):
from hermes_cli import __version__
return __version__
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception:
return "dev"
"""Canonical base version for API protocol and compatibility payloads."""
from hermes_cli.version_info import get_version_info
return get_version_info().base_version
# Default settings
+3 -5
View File
@@ -69,11 +69,9 @@ from gateway.session_transcript import TranscriptReadError
logger = logging.getLogger(__name__)
# AUTH_BIND / sign-token header values
try:
from hermes_cli import __version__ as _HERMES_VERSION
except ImportError:
_HERMES_VERSION = "0.0.0"
_APP_VERSION = _BOT_VERSION = _HERMES_VERSION
from hermes_cli.version_info import get_version_info
_APP_VERSION = _BOT_VERSION = get_version_info().base_version
_YUANBAO_INSTANCE_ID = str(HERMES_INSTANCE_ID)
_OPERATION_SYSTEM = sys.platform
+3 -6
View File
@@ -5,12 +5,9 @@ import sys
__release_date__ = "2026.9.21"
# A checkout carries no version. The release stamps ``_version.py`` into the build
# tree; without it the tree reports the placeholder, never a number read from git.
try:
from hermes_cli._version import __version__ # type: ignore[import-not-found]
except ImportError:
__version__ = "0.0.0"
# Runtime version identity no longer lives here: resolve it through
# hermes_cli.version_info.get_version_info() (install stamp → live git →
# unknown). The checkout itself carries no version constant.
def _ensure_utf8():
+3 -2
View File
@@ -196,9 +196,10 @@ def print_fast_version_info(*, check_updates: bool = True) -> None:
print(format_banner_version_label())
except Exception:
from hermes_cli import __release_date__, __version__
from hermes_cli import __release_date__
from hermes_cli.version_info import get_version_info
print(f"Hermes Agent v{__version__} ({__release_date__})")
print(f"Hermes Agent v{get_version_info().derived_version} ({__release_date__})")
print(f"Install directory: {project_root_str()}")
# Authoritative resolver first (code-scoped stamp → managed → nix → git → pip; also self-heals
# poisoned shared-home 'docker' stamps); cheap stdlib stamp probe only if it fails.
+3 -5
View File
@@ -9,6 +9,8 @@ import base64
import json
from typing import Any, Callable, Dict, Optional
from hermes_cli.version_info import get_version_info
# httpx is imported lazily (~30ms) because hermes_cli.auth is on the interactive-CLI startup path
# (credential_pool -> auxiliary_client -> cli_commands_mixin). The proxy resolves on first attribute
# access; ``from __future__ import annotations`` keeps ``httpx.Client`` annotations unevaluated.
@@ -87,11 +89,7 @@ STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
try: # Version tag for the Codex token-endpoint User-Agent; fall back if unavailable.
from hermes_cli import __version__ as _HERMES_CLI_VERSION
except Exception: # pragma: no cover - version import should always succeed
_HERMES_CLI_VERSION = "unknown"
CODEX_OAUTH_USER_AGENT = f"hermes-cli/{_HERMES_CLI_VERSION}"
CODEX_OAUTH_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
+7 -6
View File
@@ -67,7 +67,8 @@ def _skin_color(key: str, fallback: str) -> str:
# === ASCII Art & Branding ===
from hermes_cli import __version__ as VERSION, __release_date__ as RELEASE_DATE
from hermes_cli import __release_date__ as RELEASE_DATE
from hermes_cli.version_info import get_version_info
HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
[bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
@@ -209,8 +210,7 @@ def format_banner_version_label() -> str:
stamp = read_install_stamp(get_project_root())
if stamp.get("distribution") == "desktop-app":
version = stamp.get("displayVersion") or stamp.get("baseVersion") or VERSION
label = f"Hermes Agent v{version}"
label = f"Hermes Agent v{get_version_info().derived_version}"
if stamp.get("source") == "commit-build":
return f"{label} · commit-build · {str(stamp.get('commit') or '')[:12]}"
if stamp.get("tag"):
@@ -223,7 +223,7 @@ def format_banner_version_label() -> str:
return f"{label} · installer"
return label
base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})"
base = f"Hermes Agent v{get_version_info().derived_version} ({RELEASE_DATE})"
from hermes_cli.config import load_config
from hermes_cli.update_channel import resolve_update_channel
@@ -419,8 +419,9 @@ def banner_snapshot_fingerprint() -> Optional[str]:
for p in paths:
st = _quiet(p.stat)
parts.append(f"{p.name}:{st.st_mtime_ns}:{st.st_size}" if st else f"{p.name}:absent")
# Code checkout: version + git HEAD when available (post-update change).
parts.append(str(VERSION))
# Code checkout: commit when known, otherwise its derived version.
version_info = get_version_info()
parts.append(version_info.commit or version_info.derived_version)
state = get_git_banner_state()
if state:
parts.append(str(state.get("local", "")))
+2 -2
View File
@@ -653,9 +653,9 @@ def _build_compact_banner() -> str:
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
from hermes_cli import __release_date__ as _release_date
from hermes_cli import __version__ as _version
from hermes_cli.version_info import get_version_info
version_line = f"Hermes Agent v{_version} ({_release_date})"
version_line = f"Hermes Agent v{get_version_info().derived_version} ({_release_date})"
else:
version_line = format_banner_version_label()
+1 -33
View File
@@ -183,38 +183,6 @@ def _report_database_journal_modes(hermes_home: Path | None = None, version_info
check_info(f"To clear the exposure: {_wal_reset_repair_hint()}")
def _read_pyproject_version() -> str | None:
"""Read the ``[project]`` version from pyproject.toml; None for installed wheels (no pyproject) or unreadable files."""
from hermes_cli.doctor import PROJECT_ROOT
try:
text = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8-sig")
except OSError:
return None
in_project = False
for line in map(str.strip, text.splitlines()):
if line.startswith("[") and line.endswith("]"):
in_project = line == "[project]"
elif in_project and line.startswith("version") and "=" in line:
return line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"'") or None
return None
def _check_version_consistency(issues: list[str]) -> None:
"""Detect pyproject.toml vs hermes_cli.__version__ drift (a conflict resolution can revert one but not the
other). Silent no-op for installed wheels (no pyproject)."""
try:
from hermes_cli import __version__ as init_version
except Exception:
return
pyproject_version = _read_pyproject_version()
if pyproject_version is None:
return
if pyproject_version == init_version:
return check_ok("Version files consistent", f"({init_version})")
_fail_and_issue("Version mismatch between source files", f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
"Re-sync version files (e.g. run 'hermes update', or set hermes_cli/__init__.py __version__ to match pyproject.toml)", issues)
def _check_s6_supervision(issues: list[str]) -> None:
"""Under our s6 /init, report static services and the ONE host gateway slot; no-op elsewhere.
Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host)."""
@@ -501,7 +469,7 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None:
# macOS Full Disk Access (issue #52010 follow-up): one grant silences every per-folder prompt
# permanently. Silent on non-macOS.
check_macos_full_disk_access()
_check_version_consistency(f.issues)
# macOS TCC grant persistence (issue #86385): a locally-built desktop bundle whose DR is cdhash-pinned
# loses every permission grant on each rebuild; a post-#73681 identifier-pinned DR survives, but grants
# made to older binaries stay stale (toggle shows ON while macOS re-prompts).
+7 -6
View File
@@ -199,13 +199,14 @@ _API_KEYS = [
def _version_line(project_root: Path) -> str:
"""``<version> [<commit>] (<commit date>)`` — the commit date is the real "as-of" date; __release_date__
is intentionally NOT shown (reads like a wall-clock timestamp, confuses triage)."""
"""``<version> [<commit>] (<commit date>)`` using the running code identity."""
try:
from hermes_cli import __version__
except ImportError:
__version__ = "(unknown)"
ver_str = f"{__version__} [{_get_git_commit(project_root)}]"
from hermes_cli.version_info import get_version_info
version = get_version_info().derived_version
except Exception:
version = "(unknown)"
ver_str = f"{version} [{_get_git_commit(project_root)}]"
commit_date = _get_git_commit_date(project_root)
return f"{ver_str} ({commit_date})" if commit_date else ver_str
+2 -5
View File
@@ -155,10 +155,7 @@ def export_board(
attachments = copy_regular_files(kb.attachments_root(slug), staged / "attachments") if include_attachments else 0
logs = copy_regular_files(kb.worker_logs_dir(slug), staged / "logs") if include_logs else 0
try:
from hermes_cli import __version__ as hermes_version
except Exception:
hermes_version = ""
from hermes_cli.version_info import get_version_info
manifest = {
"format": ARCHIVE_FORMAT,
@@ -166,7 +163,7 @@ def export_board(
"board": slug,
"board_name": meta.get("name") or slug,
"exported_at": int(time.time()),
"hermes_version": str(hermes_version),
"hermes_version": get_version_info().base_version,
"includes": {"attachments": bool(include_attachments), "logs": bool(include_logs)},
"counts": {**counts, "attachment_files": attachments, "log_files": logs},
}
+6 -4
View File
@@ -723,8 +723,6 @@ import logging
import threading
from datetime import datetime
from hermes_cli import __version__, __release_date__
from hermes_cli.model_setup_flows import (
_model_flow_openrouter,
_model_flow_nous,
@@ -943,12 +941,16 @@ def _termux_bundled_skills_fingerprint() -> str:
git_fp = _read_git_revision_fingerprint(PROJECT_ROOT)
if git_fp:
return git_fp
from hermes_cli.version_info import get_version_info
version_info = get_version_info()
code_identity = version_info.commit or version_info.derived_version
skills_dir = PROJECT_ROOT / "skills"
try:
stat = skills_dir.stat()
return f"skills:{__version__}:{__release_date__}:{stat.st_mtime_ns}:{stat.st_size}"
return f"skills:{code_identity}:{stat.st_mtime_ns}:{stat.st_size}"
except OSError:
return f"skills:{__version__}:{__release_date__}:missing"
return f"skills:{code_identity}:missing"
def _termux_bundled_skills_stamp_path() -> Path:
+2 -2
View File
@@ -17,7 +17,7 @@ import urllib.request
from pathlib import Path
from typing import Any
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -35,7 +35,7 @@ DEFAULT_TTL_HOURS = DEFAULT_TTL_MINUTES / 60.0
DEFAULT_FETCH_TIMEOUT = 8.0
SUPPORTED_SCHEMA_VERSION = 1
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
_HERMES_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
# In-process cache, invalidated against the disk file's path + mtime and TTL. The path matters:
# under a multiplexed gateway each profile has its own ``<home>/cache/model_catalog.json``, and
+3 -3
View File
@@ -27,8 +27,8 @@ from typing import Any, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from typing import TypeGuard
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.urllib_security import open_credentialed_url
from hermes_cli.version_info import get_version_info
from hermes_cli.models_catalog_static import (
CANONICAL_PROVIDERS,
OPENROUTER_MODELS,
@@ -66,7 +66,7 @@ logger = logging.getLogger(__name__)
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
_HERMES_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
@@ -2466,7 +2466,7 @@ def probe_api_models(
alternate_base if alternate_base != normalized else None)
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com":
headers["X-Goog-Api-Client"] = f"hermes-agent/{_HERMES_VERSION}"
headers["X-Goog-Api-Client"] = f"hermes-agent/{get_version_info().base_version}"
if api_key and api_mode == "anthropic_messages":
headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01"
@@ -13,7 +13,7 @@ from time import monotonic_ns
from typing import Any, Callable
from agent import relay_runtime
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
from .shared_metrics import SharedMetricsStore
from . import shared_metrics_contract as contract
@@ -162,7 +162,7 @@ class _Runtime:
self._send_thread: threading.Thread | None = None
self._subscriber_name = f"{SUBSCRIBER_NAME}.{self.host.runtime_id}"
self.subscriber = SharedMetricsSubscriber(
SharedMetricsStore(), __version__, runtime_id=self.host.runtime_id
SharedMetricsStore(), get_version_info().base_version, runtime_id=self.host.runtime_id
)
self.relay.subscribers.register(self._subscriber_name, self.subscriber)
self.host.retain_managed_execution(self._subscriber_name)
+4 -12
View File
@@ -6,7 +6,6 @@ Split out of :mod:`hermes_cli.plugins`; validation warns and never fails a load.
from __future__ import annotations
import hashlib
import importlib.metadata
import importlib.util
import logging
import re
@@ -395,17 +394,10 @@ _VERSION_COMPARATOR_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
def running_hermes_version() -> str:
"""Version of the Hermes code that is running: ``hermes_cli.__version__``. Distribution metadata is only
a fallback — on an editable/source install it is frozen at ``pip install -e`` time and drifts from the
checkout after every ``git pull`` (dist said 0.21.0 while the code was 0.21.4), so gating on it skipped
plugins that required exactly the release the user was running."""
try:
from hermes_cli import __version__
if __version__:
return str(__version__)
except Exception:
pass
return importlib.metadata.version("hermes-agent")
"""Base release version of the Hermes code that is running."""
from hermes_cli.version_info import get_version_info
return get_version_info().base_version
_VERSION_SEGMENT_RE = re.compile(r"^\d+")
+23 -12
View File
@@ -206,24 +206,35 @@ def step_adopt_blessed_checkout(project_root: Path | None = None) -> dict:
if not is_blessed:
return {"ok": True, "skipped": "not-a-blessed-root"}
stamp = {
"schemaVersion": 2,
"updateMechanism": "self",
"source": "adoption",
"adoptedAt": datetime.now(timezone.utc).isoformat(),
}
try:
fd, tmp_name = tempfile.mkstemp(
dir=str(root), prefix=".install-stamp.", suffix=".tmp"
)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(stamp, indent=2) + "\n")
os.replace(tmp_name, stamp_path)
from hermes_cli.source_stamp import write_source_stamp
# Full checkout identity when git can answer; the birth-certificate
# minimum below only when it cannot (or the tree is read-only).
write_source_stamp(root)
except OSError as exc:
# A read-only tree (nix-like layouts without their own stamp)
# must not crash the boot — it just stays unadopted.
logger.debug("blessed-checkout adoption skipped (unwritable): %s", exc)
return {"ok": True, "skipped": f"unwritable: {exc}"}
except RuntimeError:
# No derivable git identity: write the minimal birth certificate.
stamp = {
"schemaVersion": 2,
"updateMechanism": "self",
"source": "adoption",
"adoptedAt": datetime.now(timezone.utc).isoformat(),
}
try:
fd, tmp_name = tempfile.mkstemp(
dir=str(root), prefix=".install-stamp.", suffix=".tmp"
)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(stamp, indent=2) + "\n")
os.replace(tmp_name, stamp_path)
except OSError as exc:
logger.debug("blessed-checkout adoption skipped (unwritable): %s", exc)
return {"ok": True, "skipped": f"unwritable: {exc}"}
logger.info("adopted blessed checkout at %s (updateMechanism: self)", root)
return {"ok": True, "adopted": str(root)}
+2 -2
View File
@@ -294,7 +294,7 @@ def _has_cron_jobs(staged: Path) -> bool:
def plan_install(source: str, workdir: Path, override_name: Optional[str] = None) -> InstallPlan:
"""Stage *source* and produce a plan describing what install would do."""
from hermes_cli.profiles import _canon_valid, get_profile_dir
from hermes_cli import __version__ as hermes_version
from hermes_cli.version_info import get_version_info
staged, provenance = _stage_source(source, workdir)
_reject_distribution_symlinks(staged)
manifest = read_manifest(staged)
@@ -302,7 +302,7 @@ def plan_install(source: str, workdir: Path, override_name: Optional[str] = None
raise DistributionError(
f"No {MANIFEST_FILENAME} found at the distribution root — this source is not a Hermes distribution."
)
check_hermes_requires(manifest.hermes_requires, hermes_version) # fail fast
check_hermes_requires(manifest.hermes_requires, get_version_info().base_version) # fail fast
canon = _canon_valid(override_name or manifest.name)
if canon == "default":
raise DistributionError(
+6 -1
View File
@@ -51,7 +51,7 @@ def complete_source_checkout(
build_update_products(root, desktop=desktop)
if announce:
print(announce)
return _run_post_update_maintenance(
complete = _run_post_update_maintenance(
assume_yes=assume_yes,
gateway_mode=gateway_mode,
pre_update_snapshot_id=pre_update_snapshot_id,
@@ -59,6 +59,11 @@ def complete_source_checkout(
pre_update_version=pre_update_version,
completion_message=completion_message,
)
if complete:
from hermes_cli.source_stamp import write_source_stamp
write_source_stamp(root)
return complete
def _bootstrap_command(root: Path, argv: list[str]) -> list[str]:
+55
View File
@@ -0,0 +1,55 @@
"""Atomically publish identity for a mutable source checkout."""
from __future__ import annotations
from contextlib import suppress
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import tempfile
from hermes_cli.version_info import _git_version_info, _reset_version_info_cache
def write_source_stamp(root: Path) -> dict:
"""Replace ``install-stamp.json`` with identity read from ``root`` itself."""
root = Path(root).resolve()
info = _git_version_info(root, include_untracked=True)
if info.commit is None:
raise RuntimeError(f"cannot identify source checkout at {root}")
stamp = {
"schemaVersion": 2,
"commit": info.commit,
"commitDate": info.commit_date,
"branch": info.branch,
"builtAt": datetime.now(timezone.utc).isoformat(),
"dirty": info.dirty,
"source": "git",
"distribution": None,
"updateMechanism": "self",
"baseVersion": info.base_version,
"displayVersion": info.derived_version,
"distance": info.distance,
"payload": "bootstrap",
"tag": None,
}
stamp_path = root / "install-stamp.json"
fd, tmp_name = tempfile.mkstemp(dir=root, prefix=".install-stamp.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(stamp, indent=2) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, stamp_path)
with suppress(OSError):
directory_fd = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
with suppress(OSError):
os.unlink(tmp_name)
_reset_version_info_cache()
return stamp
+6 -2
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import sys
def finish_update(*, assume_yes, gateway_mode, pre_update_snapshot_id,
def finish_update(*, root, assume_yes, gateway_mode, pre_update_snapshot_id,
had_desktop_app_before_update, pre_update_version,
plan, windows_resume) -> None:
"""Finish the selected checkout; never fetch, switch branches or restore a stash."""
@@ -22,6 +22,10 @@ def finish_update(*, assume_yes, gateway_mode, pre_update_snapshot_id,
had_desktop_app_before_update=had_desktop_app_before_update,
pre_update_version=pre_update_version,
)
if complete:
from hermes_cli.source_stamp import write_source_stamp
write_source_stamp(Path(root))
# Restart can kill this process's gateway cgroup; record its result first.
if gateway_mode:
_write_gateway_update_exit_code(complete)
@@ -103,7 +107,7 @@ def main(context: Path, result: Path) -> int:
or _desktop_dist_exists(desktop_dir))
build_update_products(root, desktop=desktop)
finish_update(
assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"],
root=root, assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"],
pre_update_snapshot_id=request.get("pre_update_snapshot_id"),
had_desktop_app_before_update=desktop,
pre_update_version=request.get("pre_update_version"),
+43 -24
View File
@@ -1,14 +1,11 @@
"""Truthful derived build-version metadata for user-facing Hermes displays.
``__version__`` remains the package/API version. This module adds a display
suffix only when it can prove the number of commits since that release.
"""Canonical runtime identity for Hermes.
Resolution order:
1. Install stamp (``install-stamp.json``) — written at build time by
``scripts/write_install_stamp.py`` for every packager (Docker, Nix, and
the desktop app). The stamp is authoritative
for packaged builds.
2. Live git — for source/dev installs with a ``.git`` directory.
2. Live git — for unstamped source/dev installs with a ``.git`` directory.
3. Unknown — no stamp and no git. The provenance is unknown.
"""
@@ -20,8 +17,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Literal, cast
from hermes_cli import __release_date__, __version__
from hermes_cli.steward import UPDATE_MECHANISMS
from hermes_cli.update_channel import STABLE_TAG_RE
@dataclass(frozen=True)
@@ -37,9 +34,15 @@ class VersionInfo:
distribution: Literal["docker", "nix", "desktop-app"] | None = None
def _derived_version(base_version: str, distance: int | None, dirty: bool = False) -> str:
def _derived_version(
base_version: str,
distance: int | None,
dirty: bool = False,
short_commit: str | None = None,
) -> str:
if distance and distance > 0:
return f"{base_version}+{distance}"
suffix = f"{distance}.g{short_commit}" if short_commit else str(distance)
return f"{base_version}+{suffix}{'.dirty' if dirty and short_commit else ''}"
if dirty and distance is None:
return f"{base_version}+?"
return base_version
@@ -126,8 +129,13 @@ def _stamp_version_info() -> VersionInfo | None:
if not commit or set(commit) == {"0"}:
# All-zero placeholder = fallback stamp, not real provenance.
return None
stamp_source = str(data.get("source") or "")
if stamp_source == "git" and (stamp_file.parent / ".git").exists():
live_commit = _run_git(stamp_file.parent, "rev-parse", "HEAD")
if live_commit and live_commit != commit:
return None
base_version = data.get("baseVersion") or __version__
base_version = data.get("baseVersion") or "unknown"
display_version = data.get("displayVersion") or base_version
distance = data.get("distance")
if isinstance(distance, str):
@@ -135,10 +143,9 @@ def _stamp_version_info() -> VersionInfo | None:
# ``source`` describes build provenance, while ``distribution`` identifies
# the package form users installed. Keep both facts intact for support.
stamp_source = str(data.get("source") or "")
source = (
cast(Literal["build", "commit-build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"], stamp_source)
if stamp_source in {"commit-build", "ci", "docker", "fallback", "local", "nix"}
if stamp_source in {"commit-build", "ci", "docker", "fallback", "git", "local", "nix"}
else "build"
)
distribution = data.get("distribution")
@@ -165,7 +172,7 @@ def _stamp_version_info() -> VersionInfo | None:
# --- Git provenance (source/dev installs) -----------------------------------
def _git_version_info(repo_dir: Path) -> VersionInfo:
def _git_version_info(repo_dir: Path, *, include_untracked: bool = False) -> VersionInfo:
commit = _run_git(repo_dir, "rev-parse", "HEAD")
# A detached HEAD has no branch. Leave the field None: every formatter
# already prints the commit separately and handles a missing branch.
@@ -178,8 +185,11 @@ def _git_version_info(repo_dir: Path) -> VersionInfo:
# -uno: skip the untracked-file scan. This runs on the startup-banner
# path, and a full working-tree walk costs real time on large or cold
# checkouts. Same semantics as write_install_stamp.py.
status_command = ["git", "status", "--porcelain"]
if not include_untracked:
status_command.append("-uno")
dirty_result = subprocess.run(
["git", "status", "--porcelain", "-uno"],
status_command,
capture_output=True,
text=True,
timeout=3,
@@ -189,18 +199,27 @@ def _git_version_info(repo_dir: Path) -> VersionInfo:
except (OSError, subprocess.SubprocessError):
dirty = False
# New releases are SemVer tags. The release-date fallback lets existing
# CalVer-tagged releases display a correct distance during the transition.
distance = None
for tag in (f"v{__version__}", f"v{__release_date__}"):
raw_distance = _run_git(repo_dir, "rev-list", "--count", f"{tag}..HEAD")
parsed_distance = _parse_nonnegative(raw_distance)
if parsed_distance is not None:
distance = parsed_distance
break
tags = _run_git(repo_dir, "tag", "--merged", "HEAD", "--list", "v[0-9]*")
releases = [
tag[1:]
for tag in (tags or "").splitlines()
if STABLE_TAG_RE.fullmatch(tag)
]
base_version = (
max(releases, key=lambda value: tuple(int(part) for part in value.split(".")))
if releases else "unknown"
)
distance = _parse_nonnegative(
_run_git(repo_dir, "rev-list", "--count", f"v{base_version}..HEAD")
) if releases else None
short_commit = _run_git(repo_dir, "rev-parse", "--short=7", "HEAD")
if base_version == "unknown" and short_commit:
display_version = f"git.{short_commit}{'.dirty' if dirty else ''}"
else:
display_version = _derived_version(base_version, distance, dirty, short_commit)
return VersionInfo(
__version__, _derived_version(__version__, distance, dirty), distance, commit, branch, "git", dirty, commit_date
base_version, display_version, distance, commit, branch, "git", dirty, commit_date
)
@@ -240,7 +259,7 @@ def get_version_info() -> VersionInfo:
# 3. Unknown — no stamp, no git
if info is None:
info = VersionInfo(__version__, __version__, None, None, None, "unknown")
info = VersionInfo("unknown", "unknown", None, None, None, "unknown")
_cached_version_info = info
return info
+9 -7
View File
@@ -16,9 +16,9 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request
from hermes_cli import __version__
from hermes_cli.config import format_docker_update_message, recommended_update_command_for_method
from hermes_cli.update_contract import COMMIT_BUILD_UPDATE_MESSAGE, is_commit_build
from hermes_cli.version_info import get_version_info
from hermes_cli.web_deps import LateState, late
from hermes_cli.web_server_gateway import _ACTION_LOG_FILES
from hermes_cli.web_routers._common import http_failure
@@ -271,25 +271,27 @@ async def check_hermes_update(force: bool = False, profile: Optional[str] = None
[{sha, summary, author, at}] (additive; existing consumers ignore it).
"""
if is_commit_build(_server_path("PROJECT_ROOT")):
from hermes_cli.steward import read_install_stamp
stamp = read_install_stamp(_server_path("PROJECT_ROOT"))
return {
"install_method": "desktop-app", "current_version": stamp.get("displayVersion") or __version__,
"install_method": "desktop-app",
"current_version": get_version_info().derived_version,
"behind": None, "update_available": False, "can_apply": False,
"update_command": "", "message": COMMIT_BUILD_UPDATE_MESSAGE,
}
if _dashboard_local_update_managed_externally():
return {
"install_method": "managed-runtime", "current_version": __version__, "behind": None,
"install_method": "managed-runtime",
"current_version": get_version_info().derived_version,
"behind": None,
"update_available": False, "can_apply": False,
"update_command": "managed outside dashboard", "message": _MANAGED_EXTERNALLY_MESSAGE,
}
install_method = detect_install_method(_server_path("PROJECT_ROOT"))
payload: Dict[str, Any] = {
"install_method": install_method, "current_version": __version__, "behind": None,
"install_method": install_method,
"current_version": get_version_info().derived_version,
"behind": None,
"update_available": False, "can_apply": install_method == "git",
"update_command": recommended_update_command_for_method(install_method), "message": None,
}
+5 -4
View File
@@ -22,8 +22,9 @@ from gateway.status import (
derive_gateway_busy, derive_gateway_drainable, normalize_updated_at, parse_active_agents,
profile_platforms_from_multiplexer, resolve_gateway_liveness, retained_gateway_state,
runtime_status_heartbeat_age_s, runtime_status_is_stale)
from hermes_cli import __version__, __release_date__
from hermes_cli import __release_date__
from hermes_cli.config import get_config_path, get_env_path
from hermes_cli.version_info import get_version_info
from hermes_constants import get_process_hermes_home, profile_name_for_home
from hermes_cli.web_models import CuratorPause, LearningNodeRef, LearningNodeEdit, DebugShareRequest
from hermes_cli.web_routers._common import config_scoped_to_thread, destructive_profile, scoped_to_thread
@@ -115,7 +116,7 @@ async def get_ssh_ownership(request: Request):
@router.get("/api/health")
async def get_health():
"""Lightweight process liveness for desktop/backend readiness probes."""
return {"ok": True, "version": __version__,
return {"ok": True, "version": get_version_info().base_version,
"auth_required": bool(getattr(app.state, "auth_required", False))}
@@ -496,7 +497,7 @@ async def get_status(profile: Optional[str] = None):
auth = _auth_gate_status()
status = {
"version": __version__, "release_date": __release_date__,
"version": get_version_info().base_version, "release_date": __release_date__,
"config_version": current_ver, "latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running, "gateway_state": gateway_state,
@@ -567,7 +568,7 @@ async def get_system_stats():
"arch": _platform.machine(), "hostname": _platform.node(),
"python_version": _platform.python_version(),
"python_impl": _platform.python_implementation(),
"hermes_version": __version__, "cpu_count": os.cpu_count()}
"hermes_version": get_version_info().base_version, "cpu_count": os.cpu_count()}
def _disk():
du = psutil.disk_usage(str(get_hermes_home()))
+2 -2
View File
@@ -33,8 +33,8 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli import __version__
from hermes_cli.config import load_config
from hermes_cli.version_info import get_version_info
try:
from fastapi import FastAPI, HTTPException, Request
@@ -308,7 +308,7 @@ def _get_pty_active_session_files(app: "FastAPI") -> dict[str, Path]:
return _app_state_default(app, "pty_active_session_files", dict)
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
app = FastAPI(title="Hermes Agent", version=get_version_info().base_version, lifespan=_lifespan)
# Memory-provider OAuth connect routes live in the memory layer, not here.
+2 -2
View File
@@ -10,9 +10,9 @@ from dataclasses import dataclass
from fastapi import HTTPException
from pathlib import Path
from typing import Any, Optional
from hermes_cli import __version__
from hermes_cli.config import OPTIONAL_ENV_VARS, write_platform_config_field
from hermes_cli.setup_hidden_env import is_setup_hidden_env as _is_setup_hidden_env
from hermes_cli.version_info import get_version_info
# Same logger the code used before extraction (record parity).
_log = logging.getLogger("hermes_cli.web_server")
@@ -374,7 +374,7 @@ def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) ->
_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com"
_TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}"
_TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{get_version_info().base_version}"
@dataclass
+2 -2
View File
@@ -38,7 +38,7 @@ from agent.message_content import flatten_message_text
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret
from agent.skill_commands import extract_user_instruction_from_skill_message
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from hermes_constants import get_hermes_home
from tools.registry import tool_error
from utils import atomic_json_write, env_var_enabled
@@ -53,7 +53,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
_OPENVIKING_SERVICE_ENDPOINT = "https://api.vikingdb.cn-beijing.volces.com/openviking"
_DEFAULT_AGENT = ""
_OPENVIKING_USER_AGENT = f"openviking-memory-hermes/{_HERMES_VERSION}"
_OPENVIKING_USER_AGENT = f"openviking-memory-hermes/{get_version_info().base_version}"
_OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE"
_OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf"
_OVCLI_SAVED_PREFIX = "ovcli.conf."
@@ -3,7 +3,7 @@
from typing import Any
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import ProviderProfile
@@ -36,7 +36,7 @@ fireworks = FireworksProfile(
default_headers={
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
},
default_aux_model="accounts/fireworks/models/glm-5p2",
# Picker safety net when the live catalog fetch fails.
+2 -2
View File
@@ -1,6 +1,6 @@
"""GMI Cloud provider profile."""
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import ProviderProfile
@@ -10,7 +10,7 @@ gmi = ProviderProfile(
signup_url="https://www.gmicloud.ai/", env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
base_url="https://api.gmi-serving.com/v1", auth_type="api_key",
# Attribution so GMI can identify Hermes Agent traffic.
default_headers={"User-Agent": f"HermesAgent/{_HERMES_VERSION}"},
default_headers={"User-Agent": f"HermesAgent/{get_version_info().base_version}"},
default_aux_model="google/gemini-3.1-flash-lite-preview",
fallback_models=(
"zai-org/GLM-5.1-FP8", "deepseek-ai/DeepSeek-V3.2", "moonshotai/Kimi-K2.5",
@@ -5,14 +5,14 @@ from typing import Any
from urllib.parse import urlparse
from agent.reasoning_effort import KIMI_K3_EFFORTS, KIMI_K3_OVERRIDES, thinking_toggle_extras
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import OMIT_TEMPERATURE, ProviderProfile
_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
}
@@ -7,7 +7,7 @@ chat_completions reasoning translations (GLM-5.2, Kimi K2, DeepSeek, Ox Alpha).
from typing import Any
from agent import reasoning_effort as re_
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import ProviderProfile
@@ -16,7 +16,7 @@ from providers.base import ProviderProfile
_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
}
+2 -2
View File
@@ -19,7 +19,7 @@ from pathlib import Path
from typing import Any, Optional
from agent.reasoning_effort import EFFORT_LADDER
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import ProviderProfile, _profile_user_agent
@@ -294,7 +294,7 @@ router = RouterProfile(
env_vars=("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY", "RAMP_ROUTER_BASE_URL"), base_url=_base_url(),
auth_type="api_key",
# Router attributes coding-agent clients by UA prefix; its WAF rejects default UAs.
default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"},
default_headers={"User-Agent": f"Hermes-Agent/{get_version_info().base_version}"},
supports_vision=True, default_aux_model="gpt-5.4-mini",
fallback_models=(), # account-scoped IDs; the picker uses fetch_models()
)
+2 -2
View File
@@ -1,13 +1,13 @@
"""xAI (Grok) provider profile."""
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from providers import register_provider
from providers.base import ProviderProfile
xai = ProviderProfile(
name="xai", aliases=("grok", "x-ai", "x.ai"), api_mode="codex_responses", env_vars=("XAI_API_KEY",),
base_url="https://api.x.ai/v1", auth_type="api_key",
default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"},
default_headers={"User-Agent": f"Hermes-Agent/{get_version_info().base_version}"},
)
register_provider(xai)
+4 -3
View File
@@ -159,10 +159,11 @@ def _send_imap_id(imap: "imaplib.IMAP4") -> None:
return
try:
try:
from hermes_cli import __version__ as _hermes_version
from hermes_cli.version_info import get_version_info
version = get_version_info().base_version
except Exception: # noqa: BLE001 — keep ID best-effort if import fails
_hermes_version = "0"
imap.xatom("ID", f'("name" "hermes-agent" "version" "{_hermes_version}" '
version = "0"
imap.xatom("ID", f'("name" "hermes-agent" "version" "{version}" '
'"vendor" "NousResearch" "support-email" "noreply@nousresearch.com")')
except Exception as e: # noqa: BLE001 — best-effort, never fatal
logger.debug("[Email] IMAP ID command not accepted: %s", e)
+3 -5
View File
@@ -60,11 +60,9 @@ except ImportError: # pragma: no cover - plugin loaded outside package context
logger = logging.getLogger(__name__)
# User-Agent prefix (``HermesAgent/<version>``) for platform-partner attribution of API calls.
try:
from hermes_cli import __version__ as _HERMES_VERSION
except Exception:
_HERMES_VERSION = "unknown"
_HERMES_SLACK_USER_AGENT_PREFIX = f"HermesAgent/{_HERMES_VERSION}"
from hermes_cli.version_info import get_version_info
_HERMES_SLACK_USER_AGENT_PREFIX = f"HermesAgent/{get_version_info().base_version}"
_SLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024
_BOOL_WORDS = frozenset({"1", "0", "true", "false", "yes", "no", "on", "off"})
+2 -2
View File
@@ -40,7 +40,7 @@ from urllib.parse import urlparse
import httpx
from agent.web_search_provider import WebSearchProvider
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
logger = logging.getLogger(__name__)
@@ -53,7 +53,7 @@ _KEY_URL = "https://www.perplexity.ai/account/api"
_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
"X-Pplx-Integration": "hermes-agent",
}
+2 -2
View File
@@ -32,8 +32,8 @@ def _profile_user_agent() -> str:
(OpenCode Zen, etc.) sit behind a WAF that returns 403 for that.
"""
try:
from hermes_cli import __version__ as _ver # lazy: avoid layer cycle at import time
return f"hermes-cli/{_ver}"
from hermes_cli.version_info import get_version_info # lazy: avoid layer cycle at import time
return f"hermes-cli/{get_version_info().base_version}"
except Exception:
return "hermes-cli"
-13
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import argparse
import datetime as dt
import os
import shutil
import subprocess
@@ -179,18 +178,6 @@ def _prepare_native(*, out: Path, ref: str, source: Path, cache: Path,
if (repo_dir / "pm/lock.json").read_bytes() != paths.lockfile_path().read_bytes():
raise ValueError("selected revision's PM lock differs from the builder; use a checkout at that revision")
build_env = os.environ if env is None else env
if version := build_env.get("HERMES_PAYLOAD_VERSION"):
from scripts.releases.stamping import stamp
epoch = build_env.get("HERMES_RELEASE_EPOCH")
if epoch:
if not epoch.isdigit():
raise ValueError("HERMES_RELEASE_EPOCH must be an integer")
instant = dt.datetime.fromtimestamp(int(epoch), tz=dt.UTC)
else:
instant = dt.datetime.now(dt.UTC)
release_date = (build_env.get("HERMES_RELEASE_DATE")
or f"{instant.year}.{instant.month}.{instant.day}")
stamp(repo_dir, version, release_date)
names = [
n for n in _bundle_package_names()
+43 -87
View File
@@ -1,112 +1,75 @@
"""Stamp a build tree with a release version. The checkout is never the target.
"""Stamp only release metadata consumed by external package builders.
The regex shapes are the canonical ones ``scripts/release.py`` already uses to
keep every mirror in lockstep. This writer differs in one respect: it takes the
tree as an argument, so a build stamps a copy and the source tree stays at the
placeholder version.
Python and npm package versions are inert source placeholders. Runtime identity
comes from ``install-stamp.json``; this adapter exists only for builders that
require a native version in their own project metadata.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import re
import tomllib
from pathlib import Path
def _rewrite(path: Path, pattern: str, replacement: str, *, count: int = 0, flags: int = 0) -> None:
def _rewrite(path: Path, pattern: str, replacement: str, *, count: int = 0, flags: int = 0) -> bool:
if not path.exists():
return
return False
raw = path.read_bytes()
newline = "\r\n" if b"\r\n" in raw else "\n"
text = raw.decode("utf-8-sig").replace("\r\n", "\n")
path.write_bytes(re.sub(pattern, replacement, text, count=count, flags=flags).replace("\n", newline).encode("utf-8"))
updated = re.sub(pattern, replacement, text, count=count, flags=flags)
path.write_bytes(updated.replace("\n", newline).encode("utf-8"))
return True
def stamp(tree: Path, version: str, release_date: str) -> list[Path]:
"""Rewrite every version mirror under ``tree``. Returns the paths written."""
def stamp(tree: Path, version: str) -> list[Path]:
"""Rewrite external builder version inputs under ``tree``."""
written: list[Path] = []
def touch(path: Path) -> None:
if path.exists():
nix_package = tree / "nix" / "hermes-agent.nix"
if _rewrite(
nix_package,
r'^ version \? "[^"]+",',
f' version ? "{version}",',
count=1,
flags=re.MULTILINE,
):
written.append(nix_package)
installer = tree / "apps" / "bootstrap-installer" / "src-tauri"
json_version = rf'\g<1>"{version}"'
for path, pattern, replacement, flags in (
(
installer / "tauri.conf.json",
r'("version"\s*:\s*)"[^"]+"',
json_version,
0,
),
(
installer / "Cargo.toml",
r'^version\s*=\s*"[^"]+"',
f'version = "{version}"',
re.MULTILINE,
),
):
if _rewrite(path, pattern, replacement, count=1, flags=flags):
written.append(path)
init = tree / "hermes_cli" / "__init__.py"
_rewrite(init, r'__release_date__\s*=\s*"[^"]+"', f'__release_date__ = "{release_date}"')
touch(init)
generated = tree / "hermes_cli" / "_version.py"
generated.write_text(
'"""Generated release identity. Do not commit."""\n\n'
f'__version__ = "{version}"\n',
encoding="utf-8",
)
written.append(generated)
pyproject = tree / "pyproject.toml"
_rewrite(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{version}"', count=1, flags=re.MULTILINE)
touch(pyproject)
nix_package = tree / "nix" / "hermes-agent.nix"
_rewrite(nix_package, r'^ version \? "[^"]+",', f' version ? "{version}",',
count=1, flags=re.MULTILINE)
touch(nix_package)
desktop = tree / "apps" / "desktop" / "package.json"
_rewrite(desktop, r'("version"\s*:\s*)"[^"]+"', rf'\g<1>"{version}"', count=1)
touch(desktop)
# The lockfile root version is the repo's own and stays put; only the
# desktop workspace entry mirrors the release.
lock = tree / "package-lock.json"
_rewrite(lock, r'("apps/desktop"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"',
rf'\g<1>"{version}"', count=1)
_rewrite(lock, r'("apps/bootstrap-installer"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"',
rf'\g<1>"{version}"', count=1)
touch(lock)
uv_lock = tree / "uv.lock"
_rewrite(uv_lock, r'(name = "hermes-agent"\nversion = )"[^"]+"', rf'\g<1>"{version}"', count=1)
touch(uv_lock)
installer = tree / "apps" / "bootstrap-installer"
json_version = rf'\g<1>"{version}"'
toml_version = f'version = "{version}"'
for name, pattern, replacement, flags in (
("package.json", r'("version"\s*:\s*)"[^"]+"', json_version, 0),
("src-tauri/tauri.conf.json", r'("version"\s*:\s*)"[^"]+"', json_version, 0),
("src-tauri/Cargo.toml", r'^version\s*=\s*"[^"]+"', toml_version, re.MULTILINE),
("src-tauri/Cargo.lock", r'(name = "bootstrap-installer"\nversion = )"[^"]+"', json_version, 0),
):
path = installer / name
_rewrite(path, pattern, replacement, count=1, flags=flags)
touch(path)
# A payload snapshot omits apps/ (INERT_SNAPSHOT_DIRS); it carries no installer to disagree with.
if (tree / "apps" / "bootstrap-installer").is_dir():
if installer.is_dir():
validate_bootstrap_version(tree, version)
return written
def validate_bootstrap_version(tree: Path, version: str) -> None:
installer = tree / "apps" / "bootstrap-installer"
package = json.loads((installer / "package.json").read_text(encoding="utf-8-sig"))
tauri = json.loads((installer / "src-tauri" / "tauri.conf.json").read_text(encoding="utf-8-sig"))
cargo = tomllib.loads((installer / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8-sig"))
root_lock = json.loads((tree / "package-lock.json").read_text(encoding="utf-8-sig"))
installer = tree / "apps" / "bootstrap-installer" / "src-tauri"
tauri = json.loads((installer / "tauri.conf.json").read_text(encoding="utf-8-sig"))
cargo = tomllib.loads((installer / "Cargo.toml").read_text(encoding="utf-8-sig"))
values = {
"workspace package": package.get("version"),
"Tauri config": tauri.get("version"),
"Cargo package": cargo.get("package", {}).get("version"),
"workspace lock": root_lock.get("packages", {}).get("apps/bootstrap-installer", {}).get("version"),
}
cargo_lock = installer / "src-tauri" / "Cargo.lock"
if cargo_lock.exists():
lock = tomllib.loads(cargo_lock.read_text(encoding="utf-8-sig"))
values["Cargo lock"] = next(
(item.get("version") for item in lock.get("package", [])
if item.get("name") in {"bootstrap-installer", "hermes-bootstrap"}), None)
mismatches = {name: value for name, value in values.items() if value != version}
if mismatches:
raise ValueError(f"Bootstrap installer version differs from {version}: {mismatches}")
@@ -116,15 +79,8 @@ def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tree", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--release-date")
parser.add_argument("--release-epoch", type=int)
args = parser.parse_args(argv)
if args.release_date and args.release_epoch is not None:
parser.error("--release-date and --release-epoch are mutually exclusive")
instant = (dt.datetime.fromtimestamp(args.release_epoch, tz=dt.UTC)
if args.release_epoch is not None else dt.datetime.now(dt.UTC))
release_date = args.release_date or f"{instant.year}.{instant.month}.{instant.day}"
stamp(args.tree.resolve(), args.version, release_date)
stamp(args.tree.resolve(), args.version)
if __name__ == "__main__":
+8 -8
View File
@@ -70,24 +70,20 @@ REPO_ABS="$(cd "$REPO" && pwd)"
PAYLOAD_ABS="$(cd "$PAYLOAD" && pwd)"
# Resolve source identity before writing output or changing payload files.
# Commit and pre-final stable modes require the checkout HEAD and staged version to agree.
if [ -n "$COMMIT_MODE" ] || [ -n "$RELEASE_COMMIT" ]; then
SELECTED_COMMIT="${RELEASE_COMMIT:-$COMMIT_MODE}"
[[ "$SELECTED_COMMIT" =~ ^[a-f0-9]{40}$ ]] || fail "commit identity requires an exact full 40-character SHA"
COMMIT="$(git -C "$REPO_ABS" rev-parse HEAD)" || fail "not a git checkout: $REPO_ABS"
[ "$COMMIT" = "$SELECTED_COMMIT" ] || fail "checkout HEAD $(echo "$COMMIT" | cut -c1-12) is not the requested commit"
PY_VERSION="$(python3 - "$REPO_ROOT" "$REPO_ABS" "$COMMIT" "$PAYLOAD_ABS/app/pyproject.toml" "$TAG" <<'PY'
import sys, tomllib
PY_VERSION="$(python3 - "$REPO_ROOT" "$REPO_ABS" "$COMMIT" "$TAG" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from scripts.releases.commit_build import version_at
version = sys.argv[5][1:] if sys.argv[5] else version_at(Path(sys.argv[2]), sys.argv[3])
staged = tomllib.loads(Path(sys.argv[4]).read_text(encoding="utf-8"))["project"]["version"]
if staged != version:
raise ValueError("payload version does not match the admitted commit")
version = sys.argv[4][1:] if sys.argv[4] else version_at(Path(sys.argv[2]), sys.argv[3])
print(version)
PY
)" || fail "commit version validation failed"
)" || fail "commit version admission failed"
if [ -n "$RELEASE_COMMIT" ]; then
DEB_VERSION="$(python3 "$HERE/deb_version.py" "$TAG")" || fail "version derivation failed for tag $TAG"
export HERMES_PAYLOAD_TAG="$TAG"
@@ -101,6 +97,7 @@ else
unset HERMES_BUILD_COMMIT
COMMIT="$(git -C "$REPO_ABS" rev-parse --verify "refs/tags/$TAG^{commit}")" \
|| fail "tag $TAG not found in $REPO_ABS"
PY_VERSION="${TAG#v}"
fi
[ -n "$TUI_PRODUCT" ] || fail "--tui-product is required (run scripts/termux/build.py)"
TUI_PRODUCT="$(cd "$TUI_PRODUCT" && pwd)"
@@ -205,6 +202,9 @@ HERMES_DESKTOP_VARIANT=runtime \
python3 "$REPO_ABS/scripts/write_install_stamp.py" \
--output "$PAYLOAD_ABS/app/install-stamp.json" \
--commit "$COMMIT" \
--base-version "$PY_VERSION" \
--display-version "$PY_VERSION" \
--distance 0 \
--distribution apt-termux \
--update-mechanism external \
--source bundle \
-3
View File
@@ -216,9 +216,6 @@ sys.path.insert(0, sys.argv[1])
from scripts.bundles.payload import snapshot
snapshot(Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]))
PY
STAMP_ARGS=(--tree "$WORK/tree" --version "${TAG#v}")
[ -z "${HERMES_RELEASE_EPOCH:-}" ] || STAMP_ARGS+=(--release-epoch "$HERMES_RELEASE_EPOCH")
[ -z "$TAG" ] || python3 "$REPO_ABS/scripts/releases/stamping.py" "${STAMP_ARGS[@]}"
[ -f "$WORK/tree/pyproject.toml" ] || fail "archived tag tree has no pyproject.toml -- bad tag?"
REPO_ROOT="$(cd "$HERE/../.." && pwd)"
DIGEST="$(cd "$REPO_ROOT" && python3 -c 'from pm.lock import termux_docker_digest; print(termux_docker_digest())')"
+23 -15
View File
@@ -16,9 +16,8 @@ checkout it describes:
``git rev-parse HEAD`` of the install repo (or ``--expect-commit``)
* ``pinnedBranch`` equals the repo's checked-out branch (or ``--expect-branch``)
* ``completedAt`` parses as an ISO-8601 UTC timestamp
* the install carries a shipped version — ``hermes_cli/__init__.py``'s
``__version__``, read through the same authority as
scripts/write_install_stamp.py
* the install carries its source identity stamp — ``install-stamp.json``
with a ``baseVersion`` whose ``commit`` matches the installed checkout HEAD
Usage:
@@ -128,8 +127,8 @@ def verify_stamp(stamp_path: Path, repo: Path, expect_commit: str | None, expect
_fail(errors, f"completedAt {completed!r} does not parse as ISO-8601")
# Cross-check the stamp against the checkout it claims to describe.
head = _git(repo, "rev-parse", "HEAD")
if commit is not None:
head = _git(repo, "rev-parse", "HEAD")
if head is None:
_fail(errors, f"could not read HEAD of {repo} to compare with pinnedCommit")
elif head != commit:
@@ -145,23 +144,32 @@ def verify_stamp(stamp_path: Path, repo: Path, expect_commit: str | None, expect
elif actual and actual != "HEAD" and actual != branch:
_fail(errors, f"pinnedBranch {branch!r} != checked-out branch {actual!r}")
# The install must carry a shipped version at all.
version, _ = _read_version(repo)
if not version:
_fail(errors, f"no __version__ found in {repo}/hermes_cli/__init__.py — the install carries no shipped version")
# The install must carry its source identity stamp, and that stamp must
# tell the truth about the checkout: baseVersion present, commit == HEAD.
base_version, canonical_commit = _read_install_stamp(repo)
if not base_version:
_fail(errors, f"no baseVersion in {repo}/install-stamp.json — the install carries no source identity stamp")
elif canonical_commit and head and canonical_commit != head:
_fail(errors, f"canonical stamp commit {canonical_commit[:12]} != installed HEAD {head[:12]}")
return errors
def _read_version(repo: Path) -> tuple[str | None, str | None]:
"""Read __version__ from the INSTALL repo (not this script's checkout)."""
init_py = repo / "hermes_cli" / "__init__.py"
def _read_install_stamp(repo: Path) -> tuple[str | None, str | None]:
"""Read baseVersion + commit from the INSTALL repo's install-stamp.json."""
stamp_path = repo / "install-stamp.json"
try:
text = init_py.read_text(encoding="utf-8-sig")
except OSError:
stamp = json.loads(stamp_path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return None, None
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
return (match.group(1) if match else None, None)
if not isinstance(stamp, dict):
return None, None
base_version = stamp.get("baseVersion")
commit = stamp.get("commit")
return (
base_version if isinstance(base_version, str) and base_version else None,
commit if isinstance(commit, str) and commit else None,
)
def main() -> int:
+20 -65
View File
@@ -7,17 +7,19 @@ docker/nix code paths.
Usage::
# From a repo root with .git available (dev/CI builds):
python scripts/write_install_stamp.py --output /path/to/install-stamp.json
# From a repo root with .git available (provenance is detected, version is not):
python scripts/write_install_stamp.py --output /path/to/install-stamp.json \
--base-version 0.19.0 --distance 0 --update-mechanism self
# Override provenance for reproducible/packaged builds:
python scripts/write_install_stamp.py --output ... \\
--commit <sha> --branch <name> --dirty \\
--base-version 0.19.0 --distance 42 --source nix --distribution nix
# Docker (no .git, commit known from CI):
# Docker (identity admitted by the workflow):
python scripts/write_install_stamp.py --output install-stamp.json \\
--source ci --distribution docker
--commit <sha> --base-version 0.19.0 --distance 0 \\
--source ci --distribution docker --update-mechanism external
"""
from __future__ import annotations
@@ -71,20 +73,6 @@ def _run_git(*args: str, cwd: str | Path = _REPO_ROOT) -> str | None:
return value if result.returncode == 0 and value else None
def _parse_release_metadata() -> tuple[str | None, str | None]:
"""Read generated release identity and the source release date."""
try:
init = (_REPO_ROOT / "hermes_cli" / "__init__.py").read_text(encoding="utf-8-sig")
except OSError:
return None, None
try:
generated = (_REPO_ROOT / "hermes_cli" / "_version.py").read_text(encoding="utf-8-sig")
except OSError:
generated = init
version = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', generated)
date = re.search(r'__release_date__\s*=\s*["\']([^"\']+)["\']', init)
return (version.group(1) if version else None, date.group(1) if date else None)
def _resolve_commit_from_env() -> str | None:
"""CI builds pass the commit via $GITHUB_SHA."""
@@ -117,27 +105,6 @@ def _resolve_dirty_from_git() -> bool:
return status is not None and len(status) > 0
def _compute_distance(base_version: str | None, release_date: str | None) -> int | None:
"""Count commits since the release tag, trying SemVer then CalVer fallback."""
if not base_version:
return None
# Try SemVer tag first, then legacy CalVer tag.
for tag in (f"v{base_version}", f"v{release_date}" if release_date else None):
if not tag:
continue
raw = _run_git("rev-list", "--count", f"{tag}..HEAD")
if raw is None:
continue
try:
count = int(raw)
except ValueError:
continue
if count >= 0:
return count
return None
def build_stamp(
*,
update_mechanism: str,
@@ -145,6 +112,7 @@ def build_stamp(
branch: str | None = None,
dirty: bool | None = None,
base_version: str | None = None,
display_version: str | None = None,
distance: int | None = None,
commit_date: int | None = None,
source: str = "local",
@@ -173,11 +141,6 @@ def build_stamp(
f"write_install_stamp: invalid --update-mechanism {update_mechanism!r} "
f"(expected one of {', '.join(UPDATE_MECHANISMS)})"
)
base_version_was_explicit = base_version is not None
_base_version, _release_date = _parse_release_metadata()
if base_version is None:
base_version = _base_version
if channel_request is not None:
from scripts.bundles.desktop_prepare import git, require_source, validate_channel_request
channel_request = validate_channel_request(channel_request)
@@ -226,33 +189,23 @@ def build_stamp(
if dirty is None:
dirty = _resolve_dirty_from_git()
dev_display_version = None
if (distance is None and not base_version_was_explicit
and base_version in {None, "0.0.0"} and commit != FALLBACK_COMMIT):
from scripts.releases.distance import dev_version
try:
dev_display_version = dev_version(_REPO_ROOT)
except (subprocess.CalledProcessError, ValueError):
base_version, distance = "0.0.0", 0
else:
base_version, separator, development = dev_display_version.partition("+")
distance = int(development.split(".", 1)[0]) if separator else 0
# Distance: explicit > reachable final release > release metadata fallback.
if distance is None:
distance = _compute_distance(base_version, _release_date)
if base_version is None:
raise ValueError(
"install stamps require an explicit base version; leave local development trees "
"unstamped so runtime identity can come from Git"
)
# Commit date: explicit > git
if commit_date is None:
commit_date = _resolve_commit_date_from_git()
# Display version
display_version = dev_display_version or base_version or ""
if dev_display_version is None and distance is not None and distance > 0:
display_version = f"{display_version}+{distance}"
elif dev_display_version is None and dirty and distance is None:
display_version = f"{display_version}+?"
if display_version is None:
display_version = base_version
if distance is not None and distance > 0:
display_version = f"{display_version}+{distance}"
elif dirty and distance is None:
display_version = f"{display_version}+?"
# The desktop artifact kind, from the one build-time selector
# HERMES_DESKTOP_VARIANT. Every stamp carries it:
@@ -332,6 +285,7 @@ def main() -> int:
parser.add_argument("--branch", default=None, help="Override branch name")
parser.add_argument("--dirty", action="store_true", default=None, help="Mark as dirty")
parser.add_argument("--base-version", default=None, help="Override base version")
parser.add_argument("--display-version", default=None, help="Exact user-facing version")
parser.add_argument("--distance", type=int, default=None, help="Override commit distance")
parser.add_argument("--commit-date", type=int, default=None, help="Override commit timestamp (Unix epoch seconds)")
parser.add_argument("--source", default="local", help="Stamp source label")
@@ -365,6 +319,7 @@ def main() -> int:
branch=args.branch,
dirty=args.dirty,
base_version=args.base_version,
display_version=args.display_version,
distance=args.distance,
commit_date=args.commit_date,
source=args.source,
@@ -0,0 +1,45 @@
"""Runtime identity contracts for ACP's human and machine surfaces."""
from types import SimpleNamespace
from typing import cast
import pytest
from acp_adapter import entry
from acp_adapter.server import HermesACPAgent
from acp_adapter.session import SessionState
def test_version_flag_displays_derived_version(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="1.2.3", derived_version="1.2.3+4.gabcdef0"),
)
entry.main(["--version"])
assert capsys.readouterr().out == "1.2.3+4.gabcdef0\n"
def test_version_slash_command_displays_derived_version(monkeypatch):
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="1.2.3", derived_version="1.2.3+4.gabcdef0"),
)
agent = object.__new__(HermesACPAgent)
state = cast(SessionState, SimpleNamespace(cwd="."))
assert agent._handle_slash_command("/version", state) == "Hermes Agent v1.2.3+4.gabcdef0"
@pytest.mark.asyncio
async def test_initialize_advertises_base_version(monkeypatch):
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="1.2.3", derived_version="1.2.3+4.gabcdef0"),
)
response = await object.__new__(HermesACPAgent).initialize()
assert response.agent_info is not None
assert response.agent_info.version == "1.2.3"
+2 -1
View File
@@ -62,7 +62,8 @@ class TestBuildAnthropicClient:
headers = kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-Title"] == "Hermes Agent"
assert headers["User-Agent"].startswith("HermesAgent/")
from hermes_cli.version_info import get_version_info
assert headers["User-Agent"] == f"HermesAgent/{get_version_info().base_version}"
# Auth branch is unchanged: x-api-key via api_key, betas kept.
assert kwargs["api_key"] == "sk-opencode-secret"
assert "anthropic-beta" in headers
+5 -5
View File
@@ -26,7 +26,7 @@ import base64
import json
from unittest.mock import MagicMock, patch
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
# ---------------------------------------------------------------------------
@@ -69,7 +69,7 @@ class TestCodexCloudflareHeaders:
def test_user_agent_advertises_hermes_version(self):
from agent.auxiliary_client import _codex_cloudflare_headers
headers = _codex_cloudflare_headers(_make_codex_jwt())
assert headers["User-Agent"] == f"HermesAgent/{__version__}"
assert headers["User-Agent"] == f"HermesAgent/{get_version_info().base_version}"
assert headers["originator"] == "hermes-agent"
@@ -171,7 +171,7 @@ class TestPrimaryClientWiring:
headers = agent._client_kwargs.get("default_headers") or {}
assert headers.get("originator") == "hermes-agent"
assert headers.get("ChatGPT-Account-ID") == "acct-rotation"
assert headers.get("User-Agent") == f"HermesAgent/{__version__}"
assert headers.get("User-Agent") == f"HermesAgent/{get_version_info().base_version}"
def test_apply_client_headers_clears_codex_headers_off_chatgpt(self):
"""Switching AWAY from chatgpt.com must drop the codex headers."""
@@ -227,7 +227,7 @@ class TestAuxiliaryClientWiring:
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
assert headers.get("originator") == "hermes-agent"
assert headers.get("ChatGPT-Account-ID") == "acct-aux-try-codex"
assert headers.get("User-Agent") == f"HermesAgent/{__version__}"
assert headers.get("User-Agent") == f"HermesAgent/{get_version_info().base_version}"
def test_resolve_provider_client_raw_codex_passes_codex_headers(self, monkeypatch):
"""The ``raw_codex=True`` branch (used by the main agent loop for direct
@@ -247,4 +247,4 @@ class TestAuxiliaryClientWiring:
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
assert headers.get("originator") == "hermes-agent"
assert headers.get("ChatGPT-Account-ID") == "acct-aux-raw-codex"
assert headers.get("User-Agent") == f"HermesAgent/{__version__}"
assert headers.get("User-Agent") == f"HermesAgent/{get_version_info().base_version}"
+3 -3
View File
@@ -12,7 +12,7 @@ import httpx
import pytest
import hermes_yaml as yaml
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@@ -109,7 +109,7 @@ def wire(profile, monkeypatch):
def _assert_identity(request, account_id="acct-attribution-test"):
assert request.headers["originator"] == "hermes-agent"
assert request.headers["user-agent"] == f"HermesAgent/{__version__}"
assert request.headers["user-agent"] == f"HermesAgent/{get_version_info().base_version}"
assert request.headers["chatgpt-account-id"] == account_id
assert "extra_headers" not in json.loads(request.content)
@@ -141,7 +141,7 @@ def test_new_identity_is_limited_to_the_official_endpoint(base_url, attributed):
assert headers["originator"] == ("hermes-agent" if attributed else "codex_cli_rs")
assert headers["User-Agent"] == (
f"HermesAgent/{__version__}"
f"HermesAgent/{get_version_info().base_version}"
if attributed else "codex_cli_rs/0.0.0 (Hermes Agent)"
)
+4 -3
View File
@@ -8,12 +8,13 @@ from __future__ import annotations
def test_nous_portal_tags_contains_product_and_client():
"""Every Nous Portal request gets BOTH the product tag and the version tag."""
from agent.portal_tags import hermes_client_tag, nous_portal_tags
"""Every Nous Portal request gets BOTH the product tag and the base-version tag."""
from agent.portal_tags import nous_portal_tags
from hermes_cli.version_info import get_version_info
tags = nous_portal_tags()
assert "product=hermes-agent" in tags
assert hermes_client_tag() in tags
assert f"client=hermes-client-v{get_version_info().base_version}" in tags
@@ -6,6 +6,7 @@ referrerUrl / appName / User-Agent flow into gateway analytics.
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from hermes_cli.version_info import get_version_info
from run_agent import AIAgent
@@ -26,7 +27,7 @@ def test_ai_gateway_base_url_applies_attribution_headers(mock_openai):
headers = agent._client_kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-Title"] == "Hermes Agent"
assert headers["User-Agent"].startswith("HermesAgent/")
assert headers["User-Agent"] == f"HermesAgent/{get_version_info().base_version}"
@patch("agent.process_bootstrap.OpenAI")
@@ -71,7 +72,7 @@ def test_opencode_go_applies_attribution_via_profile_fallback(mock_openai):
headers = agent._client_kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-Title"] == "Hermes Agent"
assert headers["User-Agent"].startswith("HermesAgent/")
assert headers["User-Agent"] == f"HermesAgent/{get_version_info().base_version}"
@patch("agent.process_bootstrap.OpenAI")
+21 -9
View File
@@ -68,14 +68,14 @@ def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase()
assert call["claim-object"] == "${{ needs.admit.outputs.claim-object }}"
desktop = workflow("desktop-bundled-release.yml")
assert "release-epoch" in desktop["jobs"]["validate"]["outputs"]
assert desktop["jobs"]["termux-deb"]["env"]["HERMES_RELEASE_EPOCH"] == \
"${{ needs.validate.outputs.release-epoch }}"
for name in ("docker", "nix", "pm-bundle"):
assert "HERMES_RELEASE_EPOCH" not in desktop["jobs"]["termux-deb"]["env"]
for name in ("docker", "nix"):
assert jobs[name]["with"]["version"] == "${{ needs.admit.outputs.version }}"
for name in ("docker", "nix", "pm-bundle"):
assert jobs[name]["with"]["release-epoch"] == "${{ needs.admit.outputs.release-epoch }}"
assert jobs["publish-docker"]["with"]["release-epoch"] == \
"${{ needs.admit.outputs.release-epoch }}"
assert "version" not in jobs["pm-bundle"]["with"]
assert "release-epoch" not in jobs["docker"]["with"]
assert "release-epoch" not in jobs["nix"]["with"]
assert "release-epoch" not in jobs["pm-bundle"]["with"]
assert "release-epoch" not in jobs["publish-docker"]["with"]
complete = jobs["complete"]["steps"]
final = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Create the final tag"))
assert complete[final]["env"]["DOCKER_MANIFEST_DIGEST"] == \
@@ -98,8 +98,9 @@ def test_release_gates_extract_consumer_facing_versions():
step["run"] for step in release_jobs["bootstrap-version"]["steps"]
if step.get("name") == "Stamp and verify the Cargo and Tauri release identity"
)
assert "uv build --wheel --sdist" in bootstrap
assert "wheel_version != expected or sdist_version != expected" in bootstrap
assert "cargo metadata" in bootstrap
assert "tauri.conf.json" in bootstrap
assert "uv build --wheel --sdist" not in bootstrap
docker = workflow("docker.yml")["jobs"]
image_check = next(
@@ -117,6 +118,17 @@ def test_release_gates_extract_consumer_facing_versions():
assert "actual != expected" in nix_check
def test_packaged_stamp_writers_receive_versions_without_rewriting_python_metadata():
docker_steps = workflow("docker.yml")["jobs"]["build"]["steps"]
assert not any(step.get("name") == "Stamp release build context" for step in docker_steps)
write = next(step["run"] for step in docker_steps if step.get("name") == "Write install stamp")
assert "--base-version" in write and "--display-version" in write
nix_steps = workflow("nix.yml")["jobs"]["flake-check"]["steps"]
prepare = next(step["run"] for step in nix_steps if step.get("name") == "Prepare isolated release source")
assert "scripts/releases/stamping.py" in prepare
def test_publication_reconciler_has_every_recovery_trigger_and_shared_lock():
stable = workflow("stable-release.yml")
publication = workflow("stable-release-publication.yml")
+2 -1
View File
@@ -766,7 +766,8 @@ class TestHealthEndpoint:
data = await resp.json()
assert "version" in data
assert isinstance(data["version"], str)
assert data["version"] != ""
from hermes_cli.version_info import get_version_info
assert data["version"] == get_version_info().base_version
# ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@ import pytest
from hermes_cli import banner, source_check
from hermes_cli.source_releases import SourceTarget
from hermes_cli.update_channel import install_id
from hermes_cli.version_info import get_version_info
@pytest.mark.parametrize("channel", ["stable", "canary", "preview-from-r2"])
@@ -27,7 +28,8 @@ def test_release_channel_never_compares_main_or_reuses_main_cache(tmp_path, monk
"update": {"installs": {install_id(root): {"path": str(root), "channel": channel}}}
}))
(get_hermes_home() / ".update_check").write_text(json.dumps({
"rev": None, "ver": banner.VERSION, "head": head, "behind": 99, "ts": 10**12,
"rev": None, "ver": get_version_info().derived_version,
"head": head, "behind": 99, "ts": 10**12,
}))
resolve = Mock(return_value=SourceTarget(channel, channel, "example/fork", commit=target, version="1.2.3"))
monkeypatch.setattr(source_check, "resolve_source_target", resolve)
+13
View File
@@ -40,6 +40,19 @@ def test_dump_uses_running_install_fallback_only(tmp_path, monkeypatch, sha, tim
assert dump._get_git_commit_date(tmp_path) == date
def test_dump_version_line_uses_derived_runtime_version(tmp_path, monkeypatch):
from hermes_cli import dump
info = VersionInfo(
"1.2.3", "1.2.3+4.gabcdef0", 4, "abcdef0" * 5 + "abcde", "main", "git"
)
monkeypatch.setattr("hermes_cli.version_info.get_version_info", lambda: info)
monkeypatch.setattr(dump, "_get_git_commit", lambda _root: "abcdef0")
monkeypatch.setattr(dump, "_get_git_commit_date", lambda _root: "")
assert dump._version_line(tmp_path) == "1.2.3+4.gabcdef0 [abcdef0]"
# --------------------------------------------------------------------------
# Authority: the requested project_root decides, never the running install
# --------------------------------------------------------------------------
+6 -5
View File
@@ -7,6 +7,7 @@ declare-only seam (surfaced, never installed).
"""
import logging
from types import SimpleNamespace
import pytest
import yaml
@@ -396,12 +397,12 @@ class TestCtxHasPlugin:
class TestRequiresHermes:
def test_gate_reads_the_running_code_version_not_dist_metadata(self, monkeypatch):
"""An editable install's dist metadata is frozen at install time (0.21.0 here) while the checkout runs
0.21.4; the gate must compare against the code that is running."""
import importlib.metadata
"""Compatibility gates use the running code's base release version."""
from hermes_cli import plugins_manifest
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.21.0")
monkeypatch.setattr("hermes_cli.__version__", "0.21.4")
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="0.21.4"),
)
assert plugins_manifest.running_hermes_version() == "0.21.4"
assert plugins_manifest.version_satisfies(">=0.21.4", plugins_manifest.running_hermes_version())
@@ -105,3 +105,39 @@ def test_adoption_keeps_the_steward_verdict_checkout(blessed_checkout):
assert sealed_steward(blessed_checkout) is None
stamp = json.loads((blessed_checkout / "install-stamp.json").read_text())
assert stamp["updateMechanism"] == "self"
def test_adoption_of_real_checkout_records_full_identity(tmp_path, monkeypatch):
"""A real git checkout gets the full source stamp (commit, base version),
not just the birth-certificate minimum."""
import subprocess
git_env = {"HOME": str(tmp_path), "PATH": os.environ["PATH"]}
home = tmp_path / ".hermes"
root = home / "hermes-agent"
root.mkdir(parents=True)
def git(*args: str) -> None:
subprocess.run(["git", *args], cwd=root, env=git_env, check=True,
capture_output=True)
git("init", "-q")
git("config", "user.name", "Hermes Test")
git("config", "user.email", "hermes@example.invalid")
(root / "tracked").write_text("release\n", encoding="utf-8")
git("add", "tracked")
git("commit", "-qm", "release")
git("tag", "v0.21.4")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root))
result = step_adopt_blessed_checkout()
assert result.get("adopted") == str(root)
stamp = json.loads((root / "install-stamp.json").read_text())
assert stamp["updateMechanism"] == "self"
assert stamp["baseVersion"] == "0.21.4"
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, env=git_env,
check=True, capture_output=True, text=True).stdout.strip()
assert stamp["commit"] == head
@@ -15,6 +15,7 @@ import stat
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -380,8 +381,10 @@ class TestInstall:
def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
# Pin current Hermes version to something well below the requirement
import hermes_cli
monkeypatch.setattr(hermes_cli, "__version__", "0.1.0", raising=False)
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="0.1.0"),
)
mf = DistributionManifest(
name="future",
@@ -0,0 +1,52 @@
"""A source checkout publishes identity only after successful completion."""
import os
from pathlib import Path
import subprocess
from hermes_cli.source_completion import complete_source_checkout
def _repo(tmp_path: Path) -> Path:
root = tmp_path / "repo"
root.mkdir()
env = {"HOME": str(tmp_path), "PATH": os.environ["PATH"]}
def git(*args: str) -> None:
subprocess.run(["git", *args], cwd=root, env=env, check=True, capture_output=True)
git("init", "-q")
git("config", "user.name", "Hermes Test")
git("config", "user.email", "hermes@example.invalid")
(root / "tracked").write_text("release\n", encoding="utf-8")
git("add", "tracked")
git("commit", "-qm", "release")
git("tag", "v0.21.4")
return root
def _completion_dependencies(monkeypatch, maintenance):
monkeypatch.setattr("hermes_cli.venv_sync.publish_launchers", lambda root: None)
monkeypatch.setattr("hermes_cli.source_build.build_update_products", lambda root, *, desktop: None)
monkeypatch.setattr("hermes_cli.update_cmd_maint._run_post_update_maintenance", maintenance)
def test_successful_source_completion_writes_checkout_identity(tmp_path, monkeypatch):
root = _repo(tmp_path)
def maintenance(**_kwargs):
assert not (root / "install-stamp.json").exists()
return True
_completion_dependencies(monkeypatch, maintenance)
assert complete_source_checkout(root, desktop=False, assume_yes=True)
assert (root / "install-stamp.json").is_file()
def test_failed_source_completion_does_not_publish_identity(tmp_path, monkeypatch):
root = _repo(tmp_path)
_completion_dependencies(monkeypatch, lambda **_kwargs: False)
assert not complete_source_checkout(root, desktop=False, assume_yes=True)
assert not (root / "install-stamp.json").exists()
+70
View File
@@ -0,0 +1,70 @@
"""Source checkout identity is written only from the checkout itself."""
import json
import os
from pathlib import Path
import subprocess
def _git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=repo, text=True, capture_output=True, check=True,
env={"HOME": str(repo.parent), "PATH": os.environ["PATH"]},
)
return result.stdout.strip()
def test_write_source_stamp_records_live_checkout_identity_atomically(tmp_path):
from hermes_cli.source_stamp import write_source_stamp
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.name", "Hermes Test")
_git(repo, "config", "user.email", "hermes@example.invalid")
(repo / "tracked").write_text("release\n", encoding="utf-8")
_git(repo, "add", "tracked")
_git(repo, "commit", "-qm", "release")
_git(repo, "tag", "v0.21.4")
(repo / "tracked").write_text("next\n", encoding="utf-8")
_git(repo, "commit", "-qam", "next")
written = write_source_stamp(repo)
stored = json.loads((repo / "install-stamp.json").read_text(encoding="utf-8-sig"))
assert stored == written
assert stored["commit"] == _git(repo, "rev-parse", "HEAD")
assert stored["baseVersion"] == "0.21.4"
assert stored["displayVersion"].startswith("0.21.4+1.g")
assert stored["source"] == "git"
assert stored["distribution"] is None
assert stored["updateMechanism"] == "self"
assert not list(repo.glob(".install-stamp.*.tmp"))
def test_stale_source_stamp_defers_to_live_checkout(tmp_path, monkeypatch):
from hermes_cli.source_stamp import write_source_stamp
from hermes_cli.version_info import _reset_version_info_cache, get_version_info
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.name", "Hermes Test")
_git(repo, "config", "user.email", "hermes@example.invalid")
(repo / "tracked").write_text("release\n", encoding="utf-8")
_git(repo, "add", "tracked")
_git(repo, "commit", "-qm", "release")
_git(repo, "tag", "v0.21.4")
write_source_stamp(repo)
(repo / "tracked").write_text("manual pull\n", encoding="utf-8")
_git(repo, "commit", "-qam", "manual pull")
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(repo))
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
_reset_version_info_cache()
info = get_version_info()
assert info.commit == _git(repo, "rev-parse", "HEAD")
assert info.derived_version.startswith("0.21.4+1.g")
assert info.source == "git"
@@ -0,0 +1,35 @@
"""Historical takeover publishes source identity before restarting runtimes."""
from hermes_cli.update_finish import finish_update
def test_successful_historical_completion_stamps_before_restart(tmp_path, monkeypatch):
import hermes_cli.source_stamp as source_stamp
import hermes_cli.update_cmd as update_cmd
events = []
monkeypatch.setattr(update_cmd, "_run_post_update_maintenance", lambda **kwargs: True)
monkeypatch.setattr(
source_stamp, "write_source_stamp", lambda root: events.append(("stamp", root)),
)
monkeypatch.setattr(
update_cmd, "_restart_gateway_fleet_after_update",
lambda plan, gateway_mode: events.append(("restart", plan)) or object(),
)
monkeypatch.setattr(
update_cmd, "_resume_windows_gateways_and_merge_outcome", lambda *args: None,
)
monkeypatch.setattr(update_cmd, "_verify_fleet_after_update", lambda *args, **kwargs: None)
finish_update(
root=tmp_path,
assume_yes=True,
gateway_mode=False,
pre_update_snapshot_id=None,
had_desktop_app_before_update=False,
pre_update_version=None,
plan=None,
windows_resume=None,
)
assert events == [("stamp", tmp_path), ("restart", None)]
+38 -61
View File
@@ -1,5 +1,6 @@
import json
from unittest.mock import MagicMock, patch
from pathlib import Path
import subprocess
from hermes_cli.version_info import (
VersionInfo,
@@ -11,10 +12,6 @@ from hermes_cli.version_info import (
)
from hermes_cli import __release_date__ as RELEASE_DATE
from hermes_cli import __version__ as VERSION
def setup_function():
_reset_version_info_cache()
@@ -106,73 +103,53 @@ def test_stamp_version_info_returns_none_when_file_missing(tmp_path, monkeypatch
assert _stamp_version_info() is None
def test_get_version_info_counts_commits_after_semver_tag(tmp_path, monkeypatch):
repo = tmp_path / "repo"
(repo / ".git").mkdir(parents=True)
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
def run(command, **_kwargs):
output = {
("git", "rev-parse", "HEAD"): "b" * 40,
("git", "branch", "--show-current"): "feature/version",
("git", "status", "--porcelain", "-uno"): "",
("git", "rev-list", "--count", f"v{VERSION}..HEAD"): "3",
("git", "log", "-1", "--format=%ct", "HEAD"): "1718662620",
}[tuple(command)]
return MagicMock(returncode=0, stdout=f"{output}\n")
with patch("hermes_cli.version_info.subprocess.run", side_effect=run):
info = get_version_info()
assert info == VersionInfo(VERSION, f"{VERSION}+3", 3, "b" * 40, "feature/version", "git", False, 1718662620)
def test_get_version_info_falls_back_to_legacy_release_date_tag(tmp_path, monkeypatch):
repo = tmp_path / "repo"
(repo / ".git").mkdir(parents=True)
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
calls = []
def run(command, **_kwargs):
calls.append(tuple(command))
if tuple(command) == ("git", "rev-list", "--count", f"v{VERSION}..HEAD"):
return MagicMock(returncode=1, stdout="")
output = {
("git", "rev-parse", "HEAD"): "c" * 40,
("git", "branch", "--show-current"): "",
("git", "status", "--porcelain", "-uno"): " M hermes_cli/version_info.py",
("git", "rev-list", "--count", f"v{RELEASE_DATE}..HEAD"): "2",
("git", "log", "-1", "--format=%ct", "HEAD"): "1718662620",
}[tuple(command)]
return MagicMock(returncode=0, stdout=f"{output}\n")
with patch("hermes_cli.version_info.subprocess.run", side_effect=run):
info = get_version_info()
assert info.derived_version == f"{VERSION}+2"
assert info.branch is None
assert info.dirty is True
assert ("git", "rev-list", "--count", f"v{RELEASE_DATE}..HEAD") in calls
def test_get_version_info_unknown_when_no_stamp_and_no_git(monkeypatch):
from pathlib import Path
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
info = get_version_info()
assert info.base_version == VERSION
assert info.derived_version == VERSION
assert info.base_version == "unknown"
assert info.derived_version == "unknown"
assert info.distance is None
assert info.commit is None
assert info.source == "unknown"
def test_get_version_info_derives_identity_from_reachable_release_tag(tmp_path, monkeypatch):
repo = tmp_path / "repo"
repo.mkdir()
def git(*args: str) -> str:
result = subprocess.run(
["git", *args], cwd=repo, text=True, capture_output=True, check=True,
env={"HOME": str(tmp_path), "PATH": __import__("os").environ["PATH"]},
)
return result.stdout.strip()
git("init", "-q")
git("config", "user.name", "Hermes Test")
git("config", "user.email", "hermes@example.invalid")
(repo / "tracked").write_text("release\n", encoding="utf-8")
git("add", "tracked")
git("commit", "-qm", "release")
git("tag", "v0.21.4")
git("tag", "v2026.9.21")
(repo / "tracked").write_text("next\n", encoding="utf-8")
git("commit", "-qam", "next")
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
info = get_version_info()
assert info.base_version == "0.21.4"
assert info.derived_version == f"0.21.4+1.g{git('rev-parse', '--short=7', 'HEAD')}"
assert info.distance == 1
assert info.commit == git("rev-parse", "HEAD")
assert info.source == "git"
def test_resolve_stamp_file_honors_install_root(tmp_path, monkeypatch):
"""Sealed installs (the Nix wrapper) point HERMES_INSTALL_ROOT at the stamp dir."""
stamp = {"commit": "e" * 40, "source": "nix", "distribution": "nix", "updateMechanism": "external"}
@@ -1,36 +0,0 @@
"""The shipped version comes from a generated module, never from the tree.
A checkout carries no version of its own: the release stamps ``hermes_cli/_version.py``
into the build tree, and a tree without that file reports the placeholder.
"""
import subprocess
import shutil
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def _version_of(tree: Path) -> str:
env = {"PYTHONPATH": str(tree)}
script = "import hermes_cli; print(hermes_cli.__version__)"
out = subprocess.run([sys.executable, "-c", script], cwd=tree, env=env,
capture_output=True, text=True, check=True)
return out.stdout.strip()
def test_generated_module_wins(tmp_path):
cli = tmp_path / "hermes_cli"
shutil.copytree(REPO / "hermes_cli", cli,
ignore=shutil.ignore_patterns("__pycache__", "_version.py"))
(cli / "_version.py").write_text('__version__ = "0.21.5"\n')
assert _version_of(tmp_path) == "0.21.5"
def test_absent_generated_module_is_the_placeholder(tmp_path):
cli = tmp_path / "hermes_cli"
shutil.copytree(REPO / "hermes_cli", cli,
ignore=shutil.ignore_patterns("__pycache__", "_version.py"))
assert _version_of(tmp_path) == "0.0.0"
+2 -2
View File
@@ -10,7 +10,7 @@ from urllib.parse import parse_qs, urlparse
import pytest
import plugins.memory.openviking as openviking_plugin
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from plugins.memory.openviking import OpenVikingMemoryProvider
@@ -756,7 +756,7 @@ class TestOpenVikingAutoRecallPrefetch:
]
assert all(headers.get("x-openviking-actor-peer", "") == peer for headers in normalized_headers)
assert all(
headers.get("user-agent") == f"openviking-memory-hermes/{_HERMES_VERSION}"
headers.get("user-agent") == f"openviking-memory-hermes/{get_version_info().base_version}"
for headers in normalized_headers
)
assert all(headers.get("x-openviking-account") == "acct" for headers in normalized_headers)
@@ -8,13 +8,13 @@ from unittest.mock import MagicMock
import pytest
import plugins.memory.openviking as openviking_module
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_cli.version_info import get_version_info
from plugins.memory.openviking import (
OpenVikingMemoryProvider,
_VikingClient,
)
_EXPECTED_USER_AGENT = f"openviking-memory-hermes/{_HERMES_VERSION}"
_EXPECTED_USER_AGENT = f"openviking-memory-hermes/{get_version_info().base_version}"
def _clear_openviking_tenant_env(monkeypatch):
@@ -39,8 +39,10 @@ class TestFireworksHeaders:
assert headers["X-Title"] == _OR_HEADERS_BASE["X-Title"]
def test_user_agent_identifies_hermes(self, fireworks_profile):
# Prefix, not the full string — the version moves every release.
assert fireworks_profile.default_headers["User-Agent"].startswith("HermesAgent/")
from hermes_cli.version_info import get_version_info
assert fireworks_profile.default_headers["User-Agent"] == (
f"HermesAgent/{get_version_info().base_version}"
)
+4 -1
View File
@@ -118,7 +118,8 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat
digest=tree_digest(entry))
canonical_before = {name: tree_digest(canonical / facts.get(name)["entry"]) for name in selected}
canonical_facts = (canonical / "facts.json").read_bytes()
env = {**os.environ, "UV_OFFLINE": "1", "UV_PYTHON_DOWNLOADS": "never", "UV_CACHE_DIR": str(tmp_path / "cache")}
env = {**os.environ, "UV_OFFLINE": "1", "UV_PYTHON_DOWNLOADS": "never",
"UV_CACHE_DIR": str(tmp_path / "cache"), "HERMES_PAYLOAD_VERSION": "9.9.9"}
subprocess.run([uv, "lock", "--python", sys.executable], cwd=repo, env=env, check=True, capture_output=True)
subprocess.run(["git", "init", str(repo)], check=True, capture_output=True)
subprocess.run(["git", "add", "."], cwd=repo, check=True)
@@ -244,6 +245,8 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat
assert calls[0]["all_extras"] is True
assert calls[0]["cache"] == tmp_path / "cache"
assert (output / "hermes-agent/pyproject.toml").is_file()
assert 'version="1.0.0"' in (output / "hermes-agent/pyproject.toml").read_text()
assert not (output / "hermes-agent/hermes_cli/_version.py").exists()
assert not (output / "hermes-agent/untracked").exists()
assert not (output / "hermes-agent/.git").exists()
facts = Facts(output / "tools/facts.json")
+3 -1
View File
@@ -42,7 +42,8 @@ def test_stamp_uses_built_commit_even_with_dispatch_sha_and_refuses_mismatch(tmp
'HERMES_BUILD_COMMIT': feature, 'HERMES_DESKTOP_VARIANT': 'bundled',
'HERMES_HOME': str(tmp_path / 'home')})
command = [sys.executable, '-I', '-S', str(repo / 'scripts/write_install_stamp.py'),
'--output', str(out), '--update-mechanism', 'app-installer']
'--output', str(out), '--base-version', '0.28.0', '--distance', '0',
'--update-mechanism', 'app-installer']
def run(*args, override=None):
return subprocess.run([*command, *args], cwd=tmp_path, env={**env, **(override or {})},
@@ -52,6 +53,7 @@ def test_stamp_uses_built_commit_even_with_dispatch_sha_and_refuses_mismatch(tmp
assert result.returncode == 0, result.stderr
data = json.loads(out.read_text(encoding='utf-8'))
assert data['commit'] == feature and data['source'] == 'commit-build'
assert data['baseVersion'] == data['displayVersion'] == '0.28.0'
assert data['branch'] is None and data['tag'] is None and data['updateMechanism'] == 'external'
result = run('--commit', feature)
assert result.returncode == 0, result.stderr
+3 -19
View File
@@ -37,15 +37,12 @@ def test_distance_ignores_the_prerelease_tag_on_the_same_commit(repo):
assert dev_version(repo) == "1.4.0+2.g" + git(repo, "rev-parse", "--short=7", "HEAD")
def test_dev_install_stamp_uses_the_reachable_final_release(repo, monkeypatch):
def test_stamp_writer_leaves_git_derived_identity_to_unstamped_local_builds(repo, monkeypatch):
from scripts import write_install_stamp
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", repo)
stamp = write_install_stamp.build_stamp(update_mechanism="external")
assert stamp["baseVersion"] == "1.4.0"
assert stamp["distance"] == 2
assert stamp["displayVersion"] == "1.4.0+2.g" + git(repo, "rev-parse", "--short=7", "HEAD")
with pytest.raises(ValueError, match="leave local development trees unstamped"):
write_install_stamp.build_stamp(update_mechanism="external")
def test_distance_is_zero_on_the_release_commit(repo):
@@ -53,16 +50,3 @@ def test_distance_is_zero_on_the_release_commit(repo):
git(repo, "checkout", "--quiet", "v1.4.0")
assert dev_version(repo) == "1.4.0"
def test_dev_install_stamp_before_the_first_final_release(repo, monkeypatch):
from scripts import write_install_stamp
git(repo, "tag", "--delete", "v1.4.0", "v1.4.0-rc")
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", repo)
stamp = write_install_stamp.build_stamp(update_mechanism="external")
assert stamp["baseVersion"] == "0.0.0"
assert stamp["distance"] == 0
assert stamp["displayVersion"] == "0.0.0"
+5 -5
View File
@@ -46,7 +46,9 @@ def _stamp_probe(tmp_path, shell):
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"), "--update-mechanism", "self"]
"--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)
@@ -62,7 +64,7 @@ def _stamp_probe(tmp_path, shell):
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')
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)
@@ -140,10 +142,8 @@ if ($LASTEXITCODE) { exit $LASTEXITCODE }
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")
distance = 0 if sha == old else 1
display = "1.0.0" if distance == 0 else f"1.0.0+1.g{sha[:7]}"
assert (stamp["baseVersion"], stamp["distance"], stamp["displayVersion"]) == (
"1.0.0", distance, display)
"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"]
+2 -3
View File
@@ -54,8 +54,7 @@ def test_deb_identity_refusal_leaves_payload_and_output_untouched(tmp_path):
assert 'not the requested commit' in invoke(['--commit', 'a' * 40])
assert 'exact full' in invoke(['--commit', 'short'])
assert 'usage:' in invoke(['--tag', 'v0.1.2', '--commit', commit])
# The version in the archived payload must agree with the admitted commit.
# Python package metadata is an inert placeholder; commit admission comes
# from the selected checkout and flows to install-stamp.json later.
(payload / 'app/pyproject.toml').write_text('[project]\nversion="9.9.9"\n', encoding='utf-8')
assert 'version' in invoke(['--commit', commit]).lower()
(payload / 'app/pyproject.toml').write_bytes((repo / 'pyproject.toml').read_bytes())
assert '--tui-product is required' in invoke(['--commit', commit])
@@ -54,16 +54,21 @@ _SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "verify-bootstrap-ve
def _install_repo(tmp_path: Path) -> Path:
"""A minimal 'installed checkout': git repo + the shipped-version field."""
"""A minimal 'installed checkout': git repo + the source identity stamp."""
repo = tmp_path / "install"
repo.mkdir()
_git(repo, "init", "-b", "main")
_git(repo, "config", "user.email", "ci@example.com")
_git(repo, "config", "user.name", "ci")
(repo / "hermes_cli").mkdir()
(repo / "hermes_cli" / "__init__.py").write_text('__version__ = "0.1.2"\n', encoding="utf-8")
(repo / "hermes_cli" / "__init__.py").write_text(
'"""Hermes CLI."""\n', encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "seed")
(repo / "install-stamp.json").write_text(json.dumps({
"schemaVersion": 2, "commit": _git(repo, "rev-parse", "HEAD"),
"baseVersion": "0.1.2", "updateMechanism": "self",
}), encoding="utf-8")
return repo
@@ -77,7 +82,8 @@ def _install_repo(tmp_path: Path) -> Path:
({"completedAt": "not-a-time"}, [], "ISO-8601"),
({"completedAt": "2026-08-30T12:00:00+01:00"}, [], "not UTC"),
({"missing": "stamp"}, [], "cannot read stamp"),
({"missing": "version"}, [], "no __version__"),
({"missing": "version"}, [], "no baseVersion"),
({"canonicalCommit": "c" * 40}, [], "canonical"),
])
def test_verifier_cli(tmp_path, changes, expect, error):
repo = _install_repo(tmp_path)
@@ -87,7 +93,11 @@ def test_verifier_cli(tmp_path, changes, expect, error):
if changes.get("missing") != "stamp":
path.write_text(json.dumps(stamp), encoding="utf-8")
if changes.get("missing") == "version":
(repo / "hermes_cli/__init__.py").unlink()
(repo / "install-stamp.json").unlink()
if "canonicalCommit" in changes:
canonical = json.loads((repo / "install-stamp.json").read_text(encoding="utf-8"))
canonical["commit"] = changes["canonicalCommit"]
(repo / "install-stamp.json").write_text(json.dumps(canonical), encoding="utf-8")
if not error:
expect = ["--expect-commit", stamp["pinnedCommit"], "--expect-branch", "main"]
env = dict(os.environ)
+25 -48
View File
@@ -1,10 +1,4 @@
"""Version stamping writes a build tree, never the checkout it was invoked from.
A release used to commit the bumped version back onto the branch. The version
now lives in the ref, so stamping is a build step: it rewrites a copy of the
tree and returns the paths it touched, and the caller's tree is unchanged.
"""
import importlib.util
"""Release stamping changes only metadata consumed by external builders."""
import json
from pathlib import Path
@@ -41,60 +35,41 @@ def _tree(root: Path) -> None:
'[[package]]\nname = "bootstrap-installer"\nversion = "0.21.1"\n', encoding="utf-8")
def test_stamping_writes_the_build_tree_and_leaves_the_source_tree(tmp_path, monkeypatch):
def test_stamping_only_writes_external_builder_inputs(tmp_path):
from scripts.releases.stamping import stamp
source = tmp_path / "source"
build = tmp_path / "build"
source.mkdir()
build.mkdir()
_tree(source)
_tree(build)
before = (source / "pyproject.toml").read_text(encoding="utf-8")
inert = [
build / "hermes_cli" / "__init__.py",
build / "pyproject.toml",
build / "uv.lock",
build / "apps" / "desktop" / "package.json",
build / "package-lock.json",
build / "apps" / "bootstrap-installer" / "package.json",
build / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.lock",
]
before = {path: path.read_bytes() for path in inert}
written = stamp(build, "0.21.5", "2026.9.22")
written = stamp(build, "0.21.5")
assert (source / "pyproject.toml").read_text(encoding="utf-8") == before
assert 'version = "0.21.5"' in (build / "pyproject.toml").read_text(encoding="utf-8")
spec = importlib.util.spec_from_file_location("stamped_version", build / "hermes_cli" / "_version.py")
assert spec is not None and spec.loader is not None
stamped_version = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stamped_version)
assert stamped_version.__version__ == "0.21.5"
assert json.loads((build / "apps" / "desktop" / "package.json").read_text())["version"] == "0.21.5"
# The lockfile root stays; stamped workspace mirrors move with their manifests.
lock = json.loads((build / "package-lock.json").read_text())
assert lock["version"] == "0.0.0"
assert lock["packages"]["apps/desktop"]["version"] == "0.21.5"
assert lock["packages"]["apps/bootstrap-installer"]["version"] == "0.21.5"
# uv.lock records the root package once, and no other package moves with it.
uv = (build / "uv.lock").read_text(encoding="utf-8")
assert uv.count('version = "0.21.5"') == 1
assert 'name = "hermes-agent"\nversion = "0.21.5"' in uv
assert not (build / "hermes_cli" / "_version.py").exists()
assert {path: path.read_bytes() for path in inert} == before
assert 'version ? "0.21.5"' in (build / "nix" / "hermes-agent.nix").read_text()
cargo_lock = (build / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.lock").read_text()
assert 'version = "0.21.5"' in cargo_lock
from scripts import write_install_stamp
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", build)
assert write_install_stamp._parse_release_metadata() == ("0.21.5", "2026.9.22")
stamp_path = build / "install-stamp.json"
stamp = write_install_stamp.write_stamp(
stamp_path, update_mechanism="external", distribution="docker",
commit="a" * 40, branch="main", dirty=False, commit_date=1, distance=0,
)
assert stamp["baseVersion"] == "0.21.5"
assert json.loads(stamp_path.read_text(encoding="utf-8"))["baseVersion"] == "0.21.5"
assert all(path.is_relative_to(build) for path in written)
tauri = build / "apps" / "bootstrap-installer" / "src-tauri" / "tauri.conf.json"
cargo = build / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.toml"
assert json.loads(tauri.read_text())["version"] == "0.21.5"
assert 'version = "0.21.5"' in cargo.read_text()
assert set(written) == {build / "nix" / "hermes-agent.nix", tauri, cargo}
tauri.write_text('{"version": "0.0.0"}\n', encoding="utf-8")
from scripts.releases.stamping import validate_bootstrap_version
with pytest.raises(ValueError, match="Tauri config"):
validate_bootstrap_version(build, "0.21.5")
def test_stamping_a_payload_snapshot_without_apps_stamps_the_runtime(tmp_path):
"""The bundled payload snapshot drops apps/ (INERT_SNAPSHOT_DIRS), so it has no installer to validate."""
def test_stamping_a_payload_snapshot_without_external_inputs_is_a_noop(tmp_path):
import shutil
from scripts.releases.stamping import stamp
@@ -102,6 +77,8 @@ def test_stamping_a_payload_snapshot_without_apps_stamps_the_runtime(tmp_path):
_tree(tmp_path)
shutil.rmtree(tmp_path / "apps")
stamp(tmp_path, "0.21.5", "2026.9.22")
shutil.rmtree(tmp_path / "nix")
assert 'version = "0.21.5"' in (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
before = (tmp_path / "pyproject.toml").read_bytes()
assert stamp(tmp_path, "0.21.5") == []
assert (tmp_path / "pyproject.toml").read_bytes() == before
+25 -2
View File
@@ -23,7 +23,8 @@ def test_cli_stamp_roundtrip(tmp_path, monkeypatch, variant, distribution, mecha
script = Path(__file__).resolve().parents[2] / "scripts/write_install_stamp.py"
env = {**os.environ, "HERMES_DESKTOP_VARIANT": variant, "HERMES_PAYLOAD_TAG": "v0.18.0", "HERMES_BUILD_COMMIT": ""}
args = [sys.executable, str(script), "--output", str(out), "--commit", "d" * 40,
"--source", "ci", "--update-mechanism", mechanism]
"--source", "ci", "--base-version", "0.18.0", "--distance", "0",
"--update-mechanism", mechanism]
if distribution:
args += ["--distribution", distribution]
result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=30)
@@ -32,6 +33,7 @@ def test_cli_stamp_roundtrip(tmp_path, monkeypatch, variant, distribution, mecha
assert {key: data[key] for key in ("source", "distribution", "updateMechanism", "payload", "tag", "commit")} == {
"source": "ci", "distribution": distribution, "updateMechanism": mechanism,
"payload": payload, "tag": tag, "commit": "d" * 40}
assert data["baseVersion"] == data["displayVersion"] == "0.18.0"
from hermes_cli.version_info import _stamp_version_info
from hermes_cli.venv_sync import _is_sealed
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(tmp_path))
@@ -70,8 +72,29 @@ def test_invalid_variant_cannot_emit_stamp(tmp_path, variant, tag, error):
out = tmp_path / "install-stamp.json"
result = subprocess.run(
[sys.executable, str(Path(__file__).resolve().parents[2] / "scripts/write_install_stamp.py"),
"--output", str(out), "--commit", "d" * 40, "--update-mechanism", "self"],
"--output", str(out), "--commit", "d" * 40, "--base-version", "0.18.0",
"--update-mechanism", "self"],
env={**os.environ, "HERMES_DESKTOP_VARIANT": variant, "HERMES_PAYLOAD_TAG": tag, "HERMES_BUILD_COMMIT": ""},
capture_output=True, text=True, timeout=30)
assert result.returncode != 0 and error in result.stderr
assert not out.exists()
def test_packaged_identity_requires_a_direct_version_and_ignores_project_metadata(tmp_path, monkeypatch):
from scripts import write_install_stamp
(tmp_path / "hermes_cli").mkdir()
(tmp_path / "hermes_cli" / "_version.py").write_text('__version__ = "9.9.9"\n', encoding="utf-8")
(tmp_path / "pyproject.toml").write_text('[project]\nversion = "8.8.8"\n', encoding="utf-8")
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", tmp_path)
with pytest.raises(ValueError, match="base version"):
write_install_stamp.build_stamp(
update_mechanism="external", commit="d" * 40, source="ci",
)
stamp = write_install_stamp.build_stamp(
update_mechanism="external", commit="d" * 40, source="ci",
base_version="1.2.3", distance=0,
)
assert stamp["baseVersion"] == stamp["displayVersion"] == "1.2.3"
+2 -2
View File
@@ -110,7 +110,7 @@ class TestGenerateGeminiTts:
def test_x_goog_api_client_header_is_set(self, tmp_path, monkeypatch, mock_gemini_response):
"""Gemini TTS requests should include Hermes client context."""
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
from tools.tts_tool import _generate_gemini_tts
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
@@ -119,7 +119,7 @@ class TestGenerateGeminiTts:
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
headers = mock_post.call_args[1]["headers"]
assert headers["X-Goog-Api-Client"] == f"hermes-agent/{__version__}"
assert headers["X-Goog-Api-Client"] == f"hermes-agent/{get_version_info().base_version}"
def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):
from tools.tts_tool import _generate_gemini_tts
+2 -2
View File
@@ -19,11 +19,11 @@ def _ok(payload):
def _assert_hermes_identity_headers(headers):
"""Both Perplexity endpoints carry the Hermes identity headers (same set as Kimi/OpenCode)
plus Perplexity's integration header."""
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-Title"] == "Hermes Agent"
assert headers["User-Agent"] == f"HermesAgent/{__version__}"
assert headers["User-Agent"] == f"HermesAgent/{get_version_info().base_version}"
assert headers["X-Pplx-Integration"] == "hermes-agent"
+2 -2
View File
@@ -36,7 +36,7 @@ class _FakeResponse:
def test_x_search_posts_responses_request(monkeypatch):
from tools.x_search_tool import x_search_tool
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
captured = {}
@@ -67,7 +67,7 @@ def test_x_search_posts_responses_request(monkeypatch):
tool_def = captured["json"]["tools"][0]
assert captured["url"] == "https://api.x.ai/v1/responses"
assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{__version__}"
assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{get_version_info().base_version}"
assert captured["json"]["model"]
assert captured["json"]["store"] is False
assert "reasoning" not in captured["json"]
@@ -0,0 +1,19 @@
"""Runtime identity contract for TUI gateway machine protocol fields."""
from types import SimpleNamespace
import hermes_cli
from tui_gateway import server
def test_session_info_advertises_base_version_and_release_date(monkeypatch):
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="1.2.3", derived_version="1.2.3+4.gabcdef0"),
)
monkeypatch.setattr(hermes_cli, "__release_date__", "2026.9.23")
info = server._session_info(None, {})
assert info["version"] == "1.2.3"
assert info["release_date"] == "2026.9.23"
+2 -2
View File
@@ -29,8 +29,8 @@ _DISCOVERY_CONTEXT_LEAD = "Could not read authorization-server metadata"
def _default_auth_request_user_agent() -> str:
"""``Hermes-Agent/<version>`` for SDK-built OAuth requests that would otherwise carry no User-Agent at
all; versioned so an operator debugging a WAF block can tell which client they are looking at."""
from hermes_cli import __version__
return f"Hermes-Agent/{__version__}"
from hermes_cli.version_info import get_version_info
return f"Hermes-Agent/{get_version_info().base_version}"
DEFAULT_AUTH_REQUEST_USER_AGENT = _default_auth_request_user_agent()
+3 -4
View File
@@ -595,11 +595,10 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
headers = {"Content-Type": "application/json"}
if urlparse(base_url).hostname == "generativelanguage.googleapis.com":
try:
import hermes_cli
version = str(hermes_cli.__version__)
from hermes_cli.version_info import get_version_info
headers["X-Goog-Api-Client"] = f"hermes-agent/{get_version_info().base_version}"
except Exception:
version = "0.0.0"
headers["X-Goog-Api-Client"] = f"hermes-agent/{version}" # partner-integration guidance
headers["X-Goog-Api-Client"] = "hermes-agent/0.0.0"
response = _post_json(f"{base_url}/models/{model}:generateContent", payload, headers, params={"key": api_key})
if response.status_code != 200:
raise RuntimeError(f"Gemini TTS API error (HTTP {response.status_code}): {_gemini_error_detail(response)}")
+3 -3
View File
@@ -52,10 +52,10 @@ def has_xai_credentials() -> bool:
def hermes_xai_user_agent() -> str:
"""Return a stable Hermes-specific User-Agent for xAI HTTP calls."""
try:
from hermes_cli import __version__
from hermes_cli.version_info import get_version_info
return f"Hermes-Agent/{get_version_info().base_version}"
except Exception:
__version__ = "unknown"
return f"Hermes-Agent/{__version__}"
return "Hermes-Agent/unknown"
def hermes_xai_default_headers() -> Dict[str, str]:
+4 -2
View File
@@ -2184,8 +2184,10 @@ def _session_info(agent, session: dict | None = None) -> dict:
"profile_name": profile_name_for_home(sess.get("profile_home")) or _current_profile_name(),
}
with contextlib.suppress(Exception):
from hermes_cli import __version__, __release_date__
info.update(version=__version__, release_date=__release_date__)
from hermes_cli import __release_date__
from hermes_cli.version_info import get_version_info
info.update(version=get_version_info().base_version, release_date=__release_date__)
live_agent = agent is not None and not sess.get("_compute_host_active")
if live_agent:
with contextlib.suppress(Exception):