mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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".
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", "")))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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())')"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user