diff --git a/README.md b/README.md index 5f7919c..b5a1476 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ This repo is now a small monorepo: The goal is simple: keep the dashboard, ingest contract, plugin source, and operational docs in one place instead of splitting the truth across repos. -For Hermes ingestion operations, the canonical operator guide is: +For Hermes ingestion operations, the canonical operator guides are: - `docs/hermes-token-analytics-plugin.md` +- `docs/hermes-token-analytics-install-runbook.md` ## What is in here @@ -24,6 +25,7 @@ For Hermes ingestion operations, the canonical operator guide is: - `docs/ai-build-prompts.md` - `docs/cloudflare-deployment.md` - `docs/hermes-token-analytics-plugin.md` + - `docs/hermes-token-analytics-install-runbook.md` - `docs/hermes-sidecar-sync.md` (migration pointer to the plugin doc) ## Product direction @@ -77,6 +79,7 @@ Supporting files: - install helper: `plugins/hermes-token-analytics/scripts/install-local-plugin.sh` - tests: `plugins/hermes-token-analytics/tests/` - operator guide: `docs/hermes-token-analytics-plugin.md` +- install runbook: `docs/hermes-token-analytics-install-runbook.md` - install helper also writes a legacy shim for `plugins/observability/token_analytics` in target Hermes checkouts ## CI / deploy model diff --git a/apps/dashboard/src/lib/runtime.ts b/apps/dashboard/src/lib/runtime.ts index 7f8227a..87d0c35 100644 --- a/apps/dashboard/src/lib/runtime.ts +++ b/apps/dashboard/src/lib/runtime.ts @@ -2,6 +2,7 @@ export type CloudflareAppEnv = { APP_ENV?: string DASHBOARD_WORKSPACE_SLUG?: string DB: D1Database + HERMES_TOKEN_ANALYTICS_SHARED_SECRET?: string INGEST_SHARED_SECRET?: string OPENAI_API_KEY?: string OPENAI_USAGE_DAYS_BACK?: string diff --git a/apps/dashboard/src/routes/api/ingest/hermes-usage.ts b/apps/dashboard/src/routes/api/ingest/hermes-usage.ts index cc02fdc..c47c83b 100644 --- a/apps/dashboard/src/routes/api/ingest/hermes-usage.ts +++ b/apps/dashboard/src/routes/api/ingest/hermes-usage.ts @@ -9,11 +9,14 @@ export const Route = createFileRoute('/api/ingest/hermes-usage')({ handlers: { POST: async ({ request }) => { const env = getRuntimeEnv() - const expectedToken = env.INGEST_SHARED_SECRET + const expectedToken = env.HERMES_TOKEN_ANALYTICS_SHARED_SECRET || env.INGEST_SHARED_SECRET if (!expectedToken) { return Response.json( - { error: 'INGEST_SHARED_SECRET is not configured in the Worker runtime.' }, + { + error: + 'HERMES_TOKEN_ANALYTICS_SHARED_SECRET is not configured in the Worker runtime. Legacy INGEST_SHARED_SECRET is still accepted.', + }, { status: 503 }, ) } diff --git a/docs/hermes-token-analytics-install-runbook.md b/docs/hermes-token-analytics-install-runbook.md new file mode 100644 index 0000000..25348d1 --- /dev/null +++ b/docs/hermes-token-analytics-install-runbook.md @@ -0,0 +1,281 @@ +# Hermes token analytics install runbook + +## TL;DR + +Use this when you need to install the Hermes token analytics plugin into a Hermes checkout and make the dashboard stay fresh. + +The key operational rule is simple: + +Use the same secret env var name on both sides: `HERMES_TOKEN_ANALYTICS_SHARED_SECRET`. + +- install and enable the plugin once +- configure the Worker secret and plugin env vars +- validate with `doctor`, `show-config`, and one manual `sync` +- create **one** Hermes cron sync job + +That single sync job handles both concerns: + +- it publishes rollups when usage exists +- it still emits a heartbeat via `generatedAt` when there were no recent requests + +Do **not** create separate heartbeat and rollup cron jobs unless the product design changes. + +## Scope + +This repo owns: + +- the dashboard app in `apps/dashboard/` +- the ingest contract at `POST /api/ingest/hermes-usage` +- the Hermes plugin source in `plugins/hermes-token-analytics/` +- the operator docs in `docs/` + +This runbook is written for agents and operators who need a repeatable install path with exact commands. + +## Prerequisites + +Before you start, confirm: + +1. you have a Hermes checkout available, usually `~/.hermes/hermes-agent` +2. you know which Hermes profile will run the cron job +3. the Cloudflare Worker has `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` configured +4. you have the matching shared secret value for `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` +5. the target host can read the Hermes `state.db` + +## Step 1. Install the plugin into the Hermes checkout + +From this repo root, copy the plugin into the target Hermes checkout: + +```bash +plugins/hermes-token-analytics/scripts/install-local-plugin.sh /path/to/hermes-agent +``` + +If you omit the argument, the helper defaults to: + +```bash +plugins/hermes-token-analytics/scripts/install-local-plugin.sh +``` + +which installs into: + +```text +~/.hermes/hermes-agent +``` + +What the helper does: + +- copies the plugin to `plugins/hermes-token-analytics/` inside the target Hermes checkout +- writes a compatibility shim at `plugins/observability/token_analytics/` +- lets older `plugins.enabled` entries keep working during migration + +## Step 2. Enable the plugin in Hermes + +Enable the plugin by its path-derived key: + +```bash +hermes plugins enable hermes-token-analytics +``` + +Then verify Hermes sees it: + +```bash +hermes plugins list +``` + +What you want to see: + +- `hermes-token-analytics` listed as enabled + +If the target install still references the old compatibility path, the shim also supports the legacy key: + +```text +observability/token_analytics +``` + +Use the real plugin key for new installs. + +## Step 3. Configure the Worker secret and plugin env vars + +Worker side: + +- set `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` in the Cloudflare Worker runtime +- legacy `INGEST_SHARED_SECRET` still works, but only as a fallback during migration + +Hermes side: + +- put the plugin settings in the env file for the Hermes profile that will run the job +- `hermes config env-path` tells you which `.env` file the active profile uses + +Recommended env block: + +```bash +export HERMES_TOKEN_ANALYTICS_DB_PATH="$HOME/.hermes/state.db" +export HERMES_TOKEN_ANALYTICS_DB_TIMEOUT="30" +export HERMES_TOKEN_ANALYTICS_ENDPOINT="https://token-usage-analytics.tevpro.workers.dev/api/ingest/hermes-usage" +export HERMES_TOKEN_ANALYTICS_SHARED_SECRET="replace-with-worker-ingest-secret" +export HERMES_TOKEN_ANALYTICS_WORKSPACE_SLUG="hermes-usage" +export HERMES_TOKEN_ANALYTICS_WORKSPACE_NAME="Hermes Usage" +export HERMES_TOKEN_ANALYTICS_ENVIRONMENT="production" +export HERMES_TOKEN_ANALYTICS_DAYS_BACK="30" +``` + +Operational notes: + +- `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` must exactly match Worker `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` +- keep `HERMES_TOKEN_ANALYTICS_WORKSPACE_SLUG` stable after go-live +- `HERMES_TOKEN_ANALYTICS_DAYS_BACK` controls how much history each sync republishes +- if the dashboard UI says **Agent**, that is still this same workspace identity underneath + +### Migration from the old shared-secret names + +If you are upgrading from an older release, migrate both sides to `HERMES_TOKEN_ANALYTICS_SHARED_SECRET`. + +1. In the Cloudflare Worker runtime, add `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` with the same value currently used for `INGEST_SHARED_SECRET`. +2. In the Hermes profile `.env`, replace `HERMES_TOKEN_ANALYTICS_TOKEN=...` with `HERMES_TOKEN_ANALYTICS_SHARED_SECRET=...`. +3. Run `hermes token-analytics doctor` and `hermes token-analytics show-config` to confirm the new name is being read. +4. After validation, remove the legacy names from both environments so future operators only see one convention. + +Temporary compatibility rules in this release: + +- Worker side: `INGEST_SHARED_SECRET` is still accepted as a fallback. +- Hermes side: `HERMES_TOKEN_ANALYTICS_TOKEN` is still accepted as a fallback. +- Preferred steady state: only `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` remains. + +## Step 4. Validate the install before scheduling anything + +Run these checks in order: + +```bash +hermes token-analytics doctor +hermes token-analytics show-config +hermes token-analytics sync +``` + +What success looks like: + +- `doctor` confirms the plugin command is callable and `state.db` is readable +- `show-config` shows the resolved endpoint, workspace fields, and redacted shared secret +- `sync` posts successfully to `/api/ingest/hermes-usage` + +If `sync` fails, stop here and fix config before adding cron. + +## Step 5. Create the scheduled job + +Install the cron wrapper once: + +```bash +hermes token-analytics install-cron-wrapper +``` + +Then create the recurring sync job: + +```bash +hermes cron create "every 15m" \ + --name "token-analytics-sync" \ + --script token_analytics_sync.sh \ + --no-agent +``` + +Why this is the right job model: + +- one job keeps current-day rollups fresh +- the same job provides heartbeat freshness through `generatedAt` +- one job avoids skew where a heartbeat says "alive" but rollups are stale + +### Heartbeat and rollup rule + +For this plugin, **heartbeat is a property of sync**, not a separate workflow. + +That means: + +- if there are recent Hermes requests, the sync sends rollups and a fresh `generatedAt` +- if there were no recent Hermes requests, the sync can still send a heartbeat-only payload +- the dashboard uses that heartbeat to show the agent/workspace as fresh instead of dead + +Unless the ingest contract changes, the correct setup is: + +- **1 plugin install** +- **1 config block** +- **1 cron sync job** + +Not two jobs. + +## Step 6. Verify the scheduled path + +After the cron job exists: + +```bash +hermes cron list --all +hermes cron run +``` + +Confirm: + +- the job is enabled +- the schedule is correct +- the cron-triggered run behaves the same as the manual `sync` + +## Normal operating procedure + +Use this sequence for changes or repairs: + +1. pause the cron job if you are rotating tokens or changing routing +2. update env/config +3. rerun `doctor` +4. rerun `show-config` +5. rerun one manual `sync` +6. resume the cron job +7. optionally `hermes cron run ` once to verify the scheduler path + +## Common failure modes + +### `401 Unauthorized` + +Usually means: + +- missing `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` +- shared secret does not match `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` + +### `503 HERMES_TOKEN_ANALYTICS_SHARED_SECRET is not configured` + +Usually means: + +- Worker secret is missing in the deployed runtime + +### Plugin command not found + +Usually means: + +- plugin files were not copied into the Hermes checkout +- plugin was not enabled with `hermes plugins enable hermes-token-analytics` + +### Cron is running but dashboard freshness is stale + +Check, in order: + +1. `hermes token-analytics doctor` +2. `hermes token-analytics show-config` +3. `hermes token-analytics sync` +4. `hermes cron list --all` +5. whether someone created duplicate or conflicting jobs + +## Quick command block + +If you just want the minimal procedure: + +```bash +# from this repo root +plugins/hermes-token-analytics/scripts/install-local-plugin.sh +hermes plugins enable hermes-token-analytics + +# configure env for the right Hermes profile, then validate +hermes token-analytics doctor +hermes token-analytics show-config +hermes token-analytics sync + +# install one recurring sync job that covers both rollups and heartbeat +hermes token-analytics install-cron-wrapper +hermes cron create "every 15m" \ + --name "token-analytics-sync" \ + --script token_analytics_sync.sh \ + --no-agent +``` diff --git a/docs/hermes-token-analytics-plugin.md b/docs/hermes-token-analytics-plugin.md index 7a72739..989223e 100644 --- a/docs/hermes-token-analytics-plugin.md +++ b/docs/hermes-token-analytics-plugin.md @@ -6,7 +6,7 @@ This integration is now a **Hermes-native Python plugin**, not a Node or bash si Operator setup is: -1. Set the Cloudflare Worker secret `INGEST_SHARED_SECRET`. +1. Set the Cloudflare Worker secret `HERMES_TOKEN_ANALYTICS_SHARED_SECRET`. 2. Export the plugin env vars shown below. 3. Validate the install with: ```bash @@ -17,7 +17,11 @@ Operator setup is: ```bash hermes token-analytics sync ``` -5. Let **Hermes cron own the schedule** by creating a cron job that runs the sync command on a cadence such as every 15 minutes. +5. Let **Hermes cron own the schedule** by creating a single cron job that runs the sync command on a cadence such as every 15 minutes. That one job covers both rollups and heartbeat freshness. + +If you just need the exact install sequence, use: + +- `docs/hermes-token-analytics-install-runbook.md` The split of responsibility is intentional: @@ -74,9 +78,9 @@ The Worker route rejects unauthenticated syncs. Set this secret in the Worker runtime: -- `INGEST_SHARED_SECRET` +- `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` -The plugin's `HERMES_TOKEN_ANALYTICS_TOKEN` must match that Worker secret. +The plugin and the Worker should use the same env var name, `HERMES_TOKEN_ANALYTICS_SHARED_SECRET`. Legacy `INGEST_SHARED_SECRET` and `HERMES_TOKEN_ANALYTICS_TOKEN` are still accepted during migration. The ingest endpoint in this repo is: @@ -85,7 +89,7 @@ POST /api/ingest/hermes-usage ``` A missing Worker secret returns `503`. -A bad or missing bearer token returns `401`. +A bad or missing shared secret returns `401`. ## Plugin configuration @@ -98,7 +102,7 @@ The plugin uses environment variables as the operator-facing source of truth. | `HERMES_TOKEN_ANALYTICS_DB_PATH` | No | `~/.hermes/state.db` | `/home/hermes/.hermes/state.db` | SQLite database path to read Hermes usage from. Override only if `state.db` lives elsewhere. | | `HERMES_TOKEN_ANALYTICS_DB_TIMEOUT` | No | `30` seconds | `30` | SQLite and network-facing timeout budget for sync operations. Increase if the host or endpoint is slow. | | `HERMES_TOKEN_ANALYTICS_ENDPOINT` | Yes | none | `https://token-usage-analytics.tevpro.workers.dev/api/ingest/hermes-usage` | Full HTTPS ingest URL for this dashboard. | -| `HERMES_TOKEN_ANALYTICS_TOKEN` | Yes | none | `tok_live_xxx` | Bearer token sent to the Worker. Must match Worker `INGEST_SHARED_SECRET`. | +| `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` | Yes | none | `tok_live_xxx` | Bearer token sent to the Worker. Must match Worker `HERMES_TOKEN_ANALYTICS_SHARED_SECRET`. | | `HERMES_TOKEN_ANALYTICS_WORKSPACE_SLUG` | No | `hermes-usage` | `prod-hermes-usage` | Stable workspace identifier used by the dashboard and D1 rows. Keep this stable once data exists. In the current dashboard UI, this workspace is presented as an agent selection. | | `HERMES_TOKEN_ANALYTICS_WORKSPACE_NAME` | No | `Hermes Usage` | `Tevpro Hermes Usage` | Human-readable workspace label shown in the dashboard. In the current UI this is the friendly agent name users see. | | `HERMES_TOKEN_ANALYTICS_ENVIRONMENT` | No | `production` | `staging` | Environment label attached to imported rollups. | @@ -110,7 +114,7 @@ The plugin uses environment variables as the operator-facing source of truth. export HERMES_TOKEN_ANALYTICS_DB_PATH="$HOME/.hermes/state.db" export HERMES_TOKEN_ANALYTICS_DB_TIMEOUT="30" export HERMES_TOKEN_ANALYTICS_ENDPOINT="https://token-usage-analytics.tevpro.workers.dev/api/ingest/hermes-usage" -export HERMES_TOKEN_ANALYTICS_TOKEN="replace-with-worker-ingest-secret" +export HERMES_TOKEN_ANALYTICS_SHARED_SECRET="replace-with-worker-ingest-secret" export HERMES_TOKEN_ANALYTICS_WORKSPACE_SLUG="hermes-usage" export HERMES_TOKEN_ANALYTICS_WORKSPACE_NAME="Hermes Usage" export HERMES_TOKEN_ANALYTICS_ENVIRONMENT="production" @@ -147,7 +151,7 @@ hermes token-analytics doctor Use it when: - first-time setup -- token rotation +- shared secret rotation - endpoint changes - DB path changes - cron jobs start failing @@ -168,7 +172,7 @@ Expected behavior: - shows endpoint - shows workspace slug/name/environment - shows days-back and timeout values -- **redacts** `HERMES_TOKEN_ANALYTICS_TOKEN` +- **redacts** `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` Use it to catch: @@ -281,7 +285,7 @@ hermes cron pause Pause before: - endpoint maintenance -- token rotation +- shared secret rotation - changing workspace routing - large manual backfills @@ -359,7 +363,7 @@ Practical checks: - sync command exits successfully - no `401 Unauthorized` -- no `503 INGEST_SHARED_SECRET is not configured` +- no `503 HERMES_TOKEN_ANALYTICS_SHARED_SECRET is not configured` - no `400` validation error from malformed payload data ### 5. Verify dashboard data moved @@ -384,7 +388,7 @@ Confirm the cron-triggered run behaves the same as the manual sync path. ## Failure modes and remediation -### Missing or bad `HERMES_TOKEN_ANALYTICS_TOKEN` +### Missing or bad `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` Symptoms: @@ -393,8 +397,8 @@ Symptoms: Fix: -- confirm `HERMES_TOKEN_ANALYTICS_TOKEN` is set -- confirm it exactly matches Worker `INGEST_SHARED_SECRET` +- confirm `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` is set +- confirm it exactly matches Worker `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` - rerun `doctor`, then `sync` ### Bad `HERMES_TOKEN_ANALYTICS_ENDPOINT` @@ -442,7 +446,7 @@ Fix: Symptoms: - `sync` fails with `503` -- response includes `INGEST_SHARED_SECRET is not configured in the Worker runtime` +- response includes `HERMES_TOKEN_ANALYTICS_SHARED_SECRET is not configured in the Worker runtime` Fix: @@ -489,8 +493,28 @@ Canonical operator guidance is now this plugin document. If you still have old env files or wrappers, map them conceptually like this: -- old sidecar endpoint/token settings -> plugin `HERMES_TOKEN_ANALYTICS_ENDPOINT` and `HERMES_TOKEN_ANALYTICS_TOKEN` +- old sidecar endpoint/token settings -> plugin `HERMES_TOKEN_ANALYTICS_ENDPOINT` and `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` - old shell scheduling -> `hermes cron ...` - old direct Python export invocation -> `hermes token-analytics sync` -The important migration outcome is simple: keep ingestion Hermes-native and keep scheduling under Hermes cron. \ No newline at end of file +### Shared-secret variable migration + +Older installs may still use: + +- Worker: `INGEST_SHARED_SECRET` +- Hermes plugin: `HERMES_TOKEN_ANALYTICS_TOKEN` + +Move both sides to: + +- `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` + +Recommended migration sequence: + +1. Add `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` to the Worker with the same secret value. +2. Add `HERMES_TOKEN_ANALYTICS_SHARED_SECRET` to the Hermes profile `.env` with the same value. +3. Run `hermes token-analytics doctor` and `hermes token-analytics sync`. +4. Once verified, delete `INGEST_SHARED_SECRET` and `HERMES_TOKEN_ANALYTICS_TOKEN`. + +This release keeps the old names as fallbacks so the migration can be done safely, but the new name should be treated as the canonical interface in docs, automation, and future installs. + +The important migration outcome is simple: keep ingestion Hermes-native and keep scheduling under Hermes cron. diff --git a/plugins/hermes-token-analytics/README.md b/plugins/hermes-token-analytics/README.md index ebb78e0..d2b3b3b 100644 --- a/plugins/hermes-token-analytics/README.md +++ b/plugins/hermes-token-analytics/README.md @@ -7,6 +7,7 @@ This directory is the source-of-truth copy of the Hermes token analytics plugin - `plugins/hermes-token-analytics/` — the plugin files as they should exist inside a Hermes checkout - `plugins/hermes-token-analytics/tests/` — plugin validation tests - `plugins/hermes-token-analytics/scripts/install-local-plugin.sh` — helper to copy the plugin into a local Hermes repo checkout +- `docs/hermes-token-analytics-install-runbook.md` — exact operator procedure for install, validation, and cron setup ## Why this exists diff --git a/plugins/hermes-token-analytics/cli.py b/plugins/hermes-token-analytics/cli.py index 44b7286..25ee967 100644 --- a/plugins/hermes-token-analytics/cli.py +++ b/plugins/hermes-token-analytics/cli.py @@ -39,6 +39,8 @@ def display_hermes_home() -> str: DEFAULT_WORKSPACE_SLUG = "hermes-usage" DEFAULT_SOURCE_LABEL = "Hermes token analytics plugin" DEFAULT_ENDPOINT_PATH = "/api/ingest/hermes-usage" +SHARED_SECRET_ENV_VAR = "HERMES_TOKEN_ANALYTICS_SHARED_SECRET" +LEGACY_SHARED_SECRET_ENV_VAR = "HERMES_TOKEN_ANALYTICS_TOKEN" HOUSTON_TIME_ZONE = ZoneInfo("America/Chicago") DEFAULT_USER_AGENT = "hermes-token-analytics/1.0" @@ -49,7 +51,7 @@ class TokenAnalyticsConfig: db_path: Path db_timeout: float endpoint: str - token: str + shared_secret: str workspace_slug: str workspace_name: str environment: str @@ -66,7 +68,7 @@ class DoctorReport: db_readable: bool db_path: str endpoint_configured: bool - token_configured: bool + shared_secret_configured: bool session_count: int | None sessions_in_window: int | None oldest_session_at: str | None @@ -121,9 +123,14 @@ def _add_common_config_args(parser: argparse.ArgumentParser) -> None: help="Analytics ingest endpoint URL", ) parser.add_argument( + "--shared-secret", "--token", - default=os.environ.get("HERMES_TOKEN_ANALYTICS_TOKEN", ""), - help="Bearer token for analytics ingest", + dest="shared_secret", + default=_shared_secret_from_env(), + help=( + "Shared secret for analytics ingest " + f"(default: {SHARED_SECRET_ENV_VAR}; legacy {LEGACY_SHARED_SECRET_ENV_VAR} also accepted)" + ), ) parser.add_argument( "--workspace-slug", @@ -183,7 +190,7 @@ def _config_from_args(args: argparse.Namespace) -> TokenAnalyticsConfig: db_path=Path(str(getattr(args, "db_path", "") or "")).expanduser(), db_timeout=max(0.1, float(getattr(args, "db_timeout", DEFAULT_DB_TIMEOUT) or DEFAULT_DB_TIMEOUT)), endpoint=endpoint, - token=str(getattr(args, "token", "") or "").strip(), + shared_secret=str(getattr(args, "shared_secret", "") or "").strip(), workspace_slug=_coalesce(str(getattr(args, "workspace_slug", "") or "").strip(), DEFAULT_WORKSPACE_SLUG), workspace_name=_coalesce(str(getattr(args, "workspace_name", "") or "").strip(), DEFAULT_WORKSPACE_NAME), environment=_coalesce(str(getattr(args, "environment", "") or "").strip(), DEFAULT_ENVIRONMENT), @@ -297,7 +304,7 @@ def diagnose_config(config: TokenAnalyticsConfig, *, require_ingest: bool) -> Do issues.append(f"Unable to read state.db: {exc}") endpoint_configured = bool(config.endpoint) - token_configured = bool(config.token) + shared_secret_configured = bool(config.shared_secret) if require_ingest or endpoint_configured: if not endpoint_configured: issues.append( @@ -306,11 +313,12 @@ def diagnose_config(config: TokenAnalyticsConfig, *, require_ingest: bool) -> Do elif not config.endpoint.startswith(("http://", "https://")): issues.append("Ingest endpoint must start with http:// or https://") - if require_ingest or token_configured: - if not token_configured: + if require_ingest or shared_secret_configured: + if not shared_secret_configured: issues.append( - "HERMES_TOKEN_ANALYTICS_TOKEN is required for sync. Store it in " - f"{display_hermes_home()}/.env or pass --token." + f"{SHARED_SECRET_ENV_VAR} is required for sync. Store it in " + f"{display_hermes_home()}/.env or pass --shared-secret. " + f"Legacy {LEGACY_SHARED_SECRET_ENV_VAR} is still accepted." ) if config.days_back > 90: @@ -328,7 +336,7 @@ def diagnose_config(config: TokenAnalyticsConfig, *, require_ingest: bool) -> Do db_readable=db_readable, db_path=str(config.db_path), endpoint_configured=endpoint_configured, - token_configured=token_configured, + shared_secret_configured=shared_secret_configured, session_count=session_count, sessions_in_window=sessions_in_window, oldest_session_at=oldest_session_at, @@ -341,8 +349,8 @@ def render_config_snapshot(config: TokenAnalyticsConfig) -> dict[str, Any]: "db_path": str(config.db_path), "db_timeout": config.db_timeout, "endpoint": config.endpoint, - "token": _mask_secret(config.token), - "token_configured": bool(config.token), + "shared_secret": _mask_secret(config.shared_secret), + "shared_secret_configured": bool(config.shared_secret), "workspace_slug": config.workspace_slug, "workspace_name": config.workspace_name, "environment": config.environment, @@ -619,7 +627,7 @@ def post_payload(config: TokenAnalyticsConfig, payload: dict[str, Any]) -> dict[ config.endpoint, data=json.dumps(payload).encode("utf-8"), headers={ - "Authorization": f"Bearer {config.token}", + "Authorization": f"Bearer {config.shared_secret}", "Content-Type": "application/json", "User-Agent": DEFAULT_USER_AGENT, "Accept": "application/json", @@ -684,7 +692,7 @@ def _print_doctor_report(report: DoctorReport, *, as_json: bool) -> None: print(f" db_exists: {'yes' if report.db_exists else 'no'}") print(f" db_readable: {'yes' if report.db_readable else 'no'}") print(f" endpoint_configured: {'yes' if report.endpoint_configured else 'no'}") - print(f" token_configured: {'yes' if report.token_configured else 'no'}") + print(f" shared_secret_configured: {'yes' if report.shared_secret_configured else 'no'}") if report.session_count is not None: print(f" session_count: {report.session_count}") if report.sessions_in_window is not None: @@ -746,6 +754,10 @@ def _env_float(name: str, default: float) -> float: return default +def _shared_secret_from_env() -> str: + return os.environ.get(SHARED_SECRET_ENV_VAR, os.environ.get(LEGACY_SHARED_SECRET_ENV_VAR, "")).strip() + + def _session_utc_day(value: Any) -> str: return _session_utc_datetime(value).strftime("%Y-%m-%d") diff --git a/plugins/hermes-token-analytics/plugin.yaml b/plugins/hermes-token-analytics/plugin.yaml index 2f5ab72..7f7341b 100644 --- a/plugins/hermes-token-analytics/plugin.yaml +++ b/plugins/hermes-token-analytics/plugin.yaml @@ -1,5 +1,5 @@ name: hermes-token-analytics -version: "0.1.1" +version: "0.1.2" description: "Hermes token usage analytics sync plugin. Adds `hermes token-analytics` commands for config inspection, validation, and exporting `state.db` rollups to an ingest endpoint." author: NousResearch kind: standalone diff --git a/plugins/hermes-token-analytics/tests/test_token_analytics_plugin.py b/plugins/hermes-token-analytics/tests/test_token_analytics_plugin.py index 1dbde23..8398a7f 100644 --- a/plugins/hermes-token-analytics/tests/test_token_analytics_plugin.py +++ b/plugins/hermes-token-analytics/tests/test_token_analytics_plugin.py @@ -137,7 +137,7 @@ def _config(db_path: Path) -> TokenAnalyticsConfig: db_path=db_path, db_timeout=1.0, endpoint="https://analytics.example.com/api/ingest/hermes-usage", - token="secret-token-value", + shared_secret="secret-token-value", workspace_slug="tevpro-hermes", workspace_name="Tevpro Hermes", environment="production", @@ -248,7 +248,7 @@ def test_diagnose_config_reports_missing_ingest_settings(tmp_path): config = _config(db_path) config.endpoint = "" - config.token = "" + config.shared_secret = "" report = diagnose_config(config, require_ingest=True) assert report.ok is False @@ -257,7 +257,7 @@ def test_diagnose_config_reports_missing_ingest_settings(tmp_path): assert report.session_count == 2 assert report.sessions_in_window == 2 assert any("HERMES_TOKEN_ANALYTICS_ENDPOINT" in item for item in report.issues) - assert any("HERMES_TOKEN_ANALYTICS_TOKEN" in item for item in report.issues) + assert any("HERMES_TOKEN_ANALYTICS_SHARED_SECRET" in item for item in report.issues) def test_render_config_snapshot_masks_secret(tmp_path): @@ -266,11 +266,20 @@ def test_render_config_snapshot_masks_secret(tmp_path): snapshot = render_config_snapshot(_config(db_path)) assert snapshot["db_path"] == str(db_path) - assert snapshot["token_configured"] is True - assert snapshot["token"] == "secr**********alue" + assert snapshot["shared_secret_configured"] is True + assert snapshot["shared_secret"] == "secr**********alue" assert snapshot["source_label"] == "Hermes token analytics plugin" +def test_shared_secret_env_prefers_new_name_and_falls_back_to_legacy(monkeypatch): + monkeypatch.delenv("HERMES_TOKEN_ANALYTICS_SHARED_SECRET", raising=False) + monkeypatch.setenv("HERMES_TOKEN_ANALYTICS_TOKEN", "legacy-secret") + assert _cli._shared_secret_from_env() == "legacy-secret" + + monkeypatch.setenv("HERMES_TOKEN_ANALYTICS_SHARED_SECRET", "new-secret") + assert _cli._shared_secret_from_env() == "new-secret" + + def test_install_cron_wrapper_writes_executable_script(tmp_path): wrapper = install_cron_wrapper(tmp_path / "token_analytics_sync.sh", force=False) assert wrapper.exists()