diff --git a/.agents/skills/hermes-compatibility-audit/SKILL.md b/.agents/skills/hermes-compatibility-audit/SKILL.md index 5ce0861..24e95ae 100644 --- a/.agents/skills/hermes-compatibility-audit/SKILL.md +++ b/.agents/skills/hermes-compatibility-audit/SKILL.md @@ -15,10 +15,11 @@ This is a read-only compatibility gate. The job is to compare official upstream Hermes behavior against the assumptions encoded in this repository and return a clear verdict, not to design or apply a migration. -Treat the repository's current PRD baseline as a repository fact, not as -current upstream truth. Today the PRD says its upstream verification baseline -was `hermes-agent` `v2026.4.13` / `v0.9.0`; the audit must still verify -whether latest stable upstream remains compatible with that plan. +Treat the repository's current runtime baseline as a repository fact, not as +current upstream truth. Today the shipped helper targets latest-only +`hermes-agent` `v2026.5.16` / `v0.14.0` or newer; the historical PRD baseline +was `v2026.4.13` / `v0.9.0`, but that legacy baseline is no longer the +supported public runtime contract. ## Scope @@ -31,9 +32,10 @@ Cover the repository's current and planned Hermes-facing contract, especially: `model.default`, `model.api_key`, `model.api`, and `model.api_mode` - model and provider selection assumptions around `hermes model`, `provider:model` syntax, curated model choice, and custom provider behavior -- auth and secret-handling assumptions around `OPENAI_API_KEY`, - `OPENAI_BASE_URL`, `auth.json`, credential pools, and the repository's - decision to keep secrets in `~/.hermes/.env` rather than `config.yaml` +- auth and secret-handling assumptions around `OPENAI_API_KEY`, unsupported + legacy endpoint env such as `OPENAI_BASE_URL`, `auth.json`, credential + pools, and the repository's decision to keep secrets in `~/.hermes/.env` + rather than `config.yaml` - workflow and CLI assumptions documented by this repository, such as `hermes model`, `hermes config set`, `hermes config path`, `hermes config env-path`, `hermes setup`, `hermes doctor`, and profile @@ -172,9 +174,9 @@ For the target stable release, gather evidence for: `model.default`, `model.api_key`, `model.api`, and `model.api_mode` - whether current Hermes guidance still routes secrets to `.env` and non-secret config to `config.yaml` -- whether `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `auth.json`, credential pools, - or `cron/jobs.json` remain active compatibility or conflict surfaces in the - stable release +- whether unsupported legacy endpoint env such as `OPENAI_BASE_URL`, + `OPENAI_API_KEY`, `auth.json`, credential pools, or `cron/jobs.json` remain + active upstream surfaces that could affect the latest-only helper contract - whether managed installs or blocked-write modes remain relevant to a local mutation helper - whether Hermes added or removed CLI surfaces relevant to this repository's diff --git a/.claude/skills/hermes-compatibility-audit/SKILL.md b/.claude/skills/hermes-compatibility-audit/SKILL.md index 5ce0861..24e95ae 100644 --- a/.claude/skills/hermes-compatibility-audit/SKILL.md +++ b/.claude/skills/hermes-compatibility-audit/SKILL.md @@ -15,10 +15,11 @@ This is a read-only compatibility gate. The job is to compare official upstream Hermes behavior against the assumptions encoded in this repository and return a clear verdict, not to design or apply a migration. -Treat the repository's current PRD baseline as a repository fact, not as -current upstream truth. Today the PRD says its upstream verification baseline -was `hermes-agent` `v2026.4.13` / `v0.9.0`; the audit must still verify -whether latest stable upstream remains compatible with that plan. +Treat the repository's current runtime baseline as a repository fact, not as +current upstream truth. Today the shipped helper targets latest-only +`hermes-agent` `v2026.5.16` / `v0.14.0` or newer; the historical PRD baseline +was `v2026.4.13` / `v0.9.0`, but that legacy baseline is no longer the +supported public runtime contract. ## Scope @@ -31,9 +32,10 @@ Cover the repository's current and planned Hermes-facing contract, especially: `model.default`, `model.api_key`, `model.api`, and `model.api_mode` - model and provider selection assumptions around `hermes model`, `provider:model` syntax, curated model choice, and custom provider behavior -- auth and secret-handling assumptions around `OPENAI_API_KEY`, - `OPENAI_BASE_URL`, `auth.json`, credential pools, and the repository's - decision to keep secrets in `~/.hermes/.env` rather than `config.yaml` +- auth and secret-handling assumptions around `OPENAI_API_KEY`, unsupported + legacy endpoint env such as `OPENAI_BASE_URL`, `auth.json`, credential + pools, and the repository's decision to keep secrets in `~/.hermes/.env` + rather than `config.yaml` - workflow and CLI assumptions documented by this repository, such as `hermes model`, `hermes config set`, `hermes config path`, `hermes config env-path`, `hermes setup`, `hermes doctor`, and profile @@ -172,9 +174,9 @@ For the target stable release, gather evidence for: `model.default`, `model.api_key`, `model.api`, and `model.api_mode` - whether current Hermes guidance still routes secrets to `.env` and non-secret config to `config.yaml` -- whether `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `auth.json`, credential pools, - or `cron/jobs.json` remain active compatibility or conflict surfaces in the - stable release +- whether unsupported legacy endpoint env such as `OPENAI_BASE_URL`, + `OPENAI_API_KEY`, `auth.json`, credential pools, or `cron/jobs.json` remain + active upstream surfaces that could affect the latest-only helper contract - whether managed installs or blocked-write modes remain relevant to a local mutation helper - whether Hermes added or removed CLI surfaces relevant to this repository's diff --git a/AGENTS.md b/AGENTS.md index a46bb58..155f6d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Current honest state: - the end-to-end public onboarding runtime is implemented - the PRD is present under `docs/specs/hermes-agent-setup-prd/spec.md` - CI, packaging, docs, contract tests, and mirrored skills are wired +- the helper targets latest-only Hermes Agent `v2026.5.16` / `v0.14.0` or + newer, and fails older Hermes versions during preflight - the current CLI resolves the active Hermes context, classifies conflicts, prompts for a hidden GonkaGate key, fetches the live catalog, intersects it with checked-in launch qualification artifacts, writes the managed Hermes @@ -36,7 +38,12 @@ Product invariants: - the canonical GonkaGate base URL is `https://api.gonkagate.com/v1` - secrets belong in `~/.hermes/.env`, not in `config.yaml` - curated model selection is product-owned +- latest-only Hermes Agent `v2026.5.16` / `v0.14.0` or newer is the supported + runtime floor - shell profile mutation is out of scope +- legacy endpoint paths such as `OPENAI_BASE_URL`, `LLM_MODEL`, root-level + `provider` / `base_url`, and legacy `custom_providers` are out of scope for + the public flow - arbitrary custom base URLs are out of scope for the public flow - v1 launch scope is Linux, macOS, and WSL2 only - public onboarding inherits current GonkaGate Terms availability boundaries; diff --git a/README.md b/README.md index cb4768b..e77900c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ OpenAI-compatible endpoint through `provider: custom` and You should also have: - `hermes-agent` available on your machine +- Hermes Agent `v2026.5.16` / `v0.14.0` or newer - a GonkaGate API key - an interactive terminal - Linux, macOS, or WSL2 @@ -84,6 +85,9 @@ When setup succeeds, the helper writes only the GonkaGate-managed surface: The shipped helper intentionally stays narrow: - it does not replace `hermes setup` +- it does not support legacy endpoint paths such as `OPENAI_BASE_URL`, + `LLM_MODEL`, root-level `provider` / `base_url`, or legacy + `custom_providers` - it does not accept arbitrary custom base URLs - it does not mutate shell profiles - it does not mutate `auth.json` credential pools @@ -103,3 +107,4 @@ If you need general Hermes setup help or deeper product context first, start at - [How It Works](./docs/how-it-works.md) - [Security](./docs/security.md) - [Product Spec](./docs/specs/hermes-agent-setup-prd/spec.md) +- [Latest Hermes Contract Adaptation](./docs/specs/hermes-latest-contract-adaptation/spec.md) diff --git a/docs/README.md b/docs/README.md index 060b995..ecc3b40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ This repository does not currently contain: ## Current Contract Documents - [Hermes Agent Setup PRD](./specs/hermes-agent-setup-prd/spec.md) +- [Hermes Latest Contract Adaptation](./specs/hermes-latest-contract-adaptation/spec.md) - [How It Works](./how-it-works.md) - [Security](./security.md) @@ -36,7 +37,8 @@ This repository does not currently contain: ## Notes -- the PRD remains the main product contract +- the PRD remains the historical v1 product contract; the latest-contract + adaptation records the current Hermes `v2026.5.16` runtime update - launch qualification artifacts are part of the shipped model-selection contract - historical documents must be labeled explicitly so scaffold-era planning diff --git a/docs/how-it-works.md b/docs/how-it-works.md index b3b6f98..3499a6a 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -19,20 +19,20 @@ Today the repository ships: - Hermes preconditions, path resolution, normalized reads, conflict classification, catalog access, model selection, write planning, backups, rollback, and success/error UX under `src/` -- checked-in launch qualification artifacts for the pinned Hermes release +- checked-in launch qualification artifacts for the latest-only Hermes release - docs, contract tests, and mirrored contributor skills ## Install Flow -1. Check Node, TTY, supported platform, Hermes availability, and managed-write - blockers before prompting for anything. +1. Check Node, TTY, supported platform, Hermes availability, Hermes version + floor, and managed-write blockers before prompting for anything. 2. Resolve the active Hermes config context through `hermes config path`, `hermes config env-path`, and optional `--profile `. 3. Read `config.yaml`, `.env`, `auth.json`, and `cron/jobs.json`, then build a - release-pinned normalized Hermes view that includes `${VAR}` expansion and - legacy root-level `provider` / `base_url` migration into `model.*`. -4. Classify shared `OPENAI_API_KEY`, `OPENAI_BASE_URL`, matching - `custom_providers` / `providers:`, and matching `auth.json` credential-pool + latest-only normalized Hermes view with `${VAR}` expansion for current + supported surfaces. +4. Classify shared `OPENAI_API_KEY`, current `providers:` conflicts, legacy + `custom_providers` residue, and matching `auth.json` credential-pool conflicts before any secret prompt or write plan is built. 5. Prompt for a hidden GonkaGate API key and validate the `gp-...` shape before any network call. @@ -42,8 +42,8 @@ Today the repository ships: 7. Pick one qualified live model. Interactive mode keeps the model picker visible; single-option flows may auto-select that one qualified model. 8. Build one deterministic pre-write review that includes planned config - changes, planned `.env` cleanup, takeover confirmations, and matching - provider scrub actions. + changes and takeover confirmations. Legacy endpoint paths are not cleaned or + migrated by the helper. 9. Create same-run backups, write `config.yaml` first, write `.env` second, and roll back `config.yaml` by pre-run state if the later `.env` write fails. @@ -56,15 +56,17 @@ The helper intentionally stays narrow: - it owns the GonkaGate onboarding path, not general Hermes bootstrap - it manages only `model.provider`, `model.base_url`, `model.default`, and - `OPENAI_API_KEY`, plus conflict-only cleanup allowed by the PRD + `OPENAI_API_KEY`, plus current `model.api_key`, `model.api`, and + incompatible `model.api_mode` cleanup when those compete with the managed + main endpoint - it does not mutate `auth.json` credential pools - it does not mutate shell profiles - it does not accept arbitrary custom base URLs Matching custom credential pools remain a blocking manual-resolution case in -v1. Matching provider entries are scrubbed only when one on-disk entry can be -cleaned within the allowed field set and the user confirms the consolidated -review. +v1. Legacy `custom_providers` entries and current `providers:` entries with +competing selectors are also blocking manual-resolution cases; the helper does +not scrub provider registries. ## Qualification And Verification @@ -74,9 +76,23 @@ The runtime is curated-model-first: `docs/launch-qualification/hermes-agent-setup/` are eligible - the helper still requires those models to remain visible in the live `/v1/models` catalog before offering them +- live catalog entries without checked-in qualification artifacts are ignored + rather than exposed as ad hoc model choices - `GET /v1/models` is an auth plus live-catalog signal, not proof of prepaid balance or end-to-end readiness for the first billable request +Current proof coverage for the catalog boundary: + +- `test/catalog-client.test.ts` verifies the canonical + `https://api.gonkagate.com/v1/models` URL, Bearer auth, malformed payload + rejection, terminal auth failures, retryable 5xx and 429 behavior, quota + shaped failures, and retry exhaustion. +- `test/qualified-models.test.ts` verifies the intersection between the live + catalog and checked-in qualification artifacts, including the rule that + live-only unqualified entries are not selectable. +- `test/e2e-onboard.test.ts` verifies catalog failures abort before Hermes + files are written. + Use the maintainer scripts under `scripts/launch-qualification/` to prepare clean-home qualification runs, build the checked-in artifact, and validate the artifact tree. diff --git a/docs/launch-qualification/hermes-agent-setup/README.md b/docs/launch-qualification/hermes-agent-setup/README.md index 190e4e1..456fab6 100644 --- a/docs/launch-qualification/hermes-agent-setup/README.md +++ b/docs/launch-qualification/hermes-agent-setup/README.md @@ -8,8 +8,8 @@ Runtime policy: - only models with a checked-in artifact here may be considered allowlisted - the helper intersects those artifacts with the live GonkaGate `/v1/models` catalog before presenting any model choice -- artifacts are pinned to the qualified Hermes release contract, currently - `v2026.4.13` +- artifacts are pinned to the latest-only qualified Hermes release contract, + currently `v2026.5.16` - maintainer tooling for preparing sessions, building artifacts, and validating this tree lives under `scripts/launch-qualification/` diff --git a/docs/launch-qualification/hermes-agent-setup/v2026.4.13/moonshotai-kimi-k2-6.md b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md similarity index 73% rename from docs/launch-qualification/hermes-agent-setup/v2026.4.13/moonshotai-kimi-k2-6.md rename to docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md index 5ccb3ac..95178e9 100644 --- a/docs/launch-qualification/hermes-agent-setup/v2026.4.13/moonshotai-kimi-k2-6.md +++ b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md @@ -1,8 +1,8 @@ --- modelId: moonshotai/kimi-k2.6 -qualifiedOn: 2026-04-29 -hermesReleaseTag: v2026.4.13 -hermesCommit: launch-qualification-recorded-internal +qualifiedOn: 2026-05-24 +hermesReleaseTag: v2026.5.16 +hermesCommit: a91a57fa5a13d516c38b07a141a9ce8a3daabeb0 osCoverage: - linux - macos @@ -13,7 +13,7 @@ recommended: true # `moonshotai/kimi-k2.6` This record defines the checked-in allowlist entry consumed by the shipped -runtime for the pinned Hermes release. +runtime for the latest-only Hermes release contract. ## Sanitized Config Shape @@ -38,9 +38,9 @@ qualification workflow and summarized by this checked-in allowlist record. ## Streaming Turn Saved streaming qualification evidence is tracked in the same release -qualification workflow for the pinned Hermes release. +qualification workflow for the latest-only Hermes release. ## Harmless Tool-Use Turn Saved harmless tool-use qualification evidence is tracked in the same release -qualification workflow for the pinned Hermes release. +qualification workflow for the latest-only Hermes release. diff --git a/docs/launch-qualification/hermes-agent-setup/v2026.4.13/qwen-qwen3-235b-a22b-instruct-2507-fp8.md b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/qwen-qwen3-235b-a22b-instruct-2507-fp8.md similarity index 74% rename from docs/launch-qualification/hermes-agent-setup/v2026.4.13/qwen-qwen3-235b-a22b-instruct-2507-fp8.md rename to docs/launch-qualification/hermes-agent-setup/v2026.5.16/qwen-qwen3-235b-a22b-instruct-2507-fp8.md index 0bd9079..c855945 100644 --- a/docs/launch-qualification/hermes-agent-setup/v2026.4.13/qwen-qwen3-235b-a22b-instruct-2507-fp8.md +++ b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/qwen-qwen3-235b-a22b-instruct-2507-fp8.md @@ -1,8 +1,8 @@ --- modelId: qwen/qwen3-235b-a22b-instruct-2507-fp8 -qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 -hermesCommit: launch-qualification-recorded-internal +qualifiedOn: 2026-05-24 +hermesReleaseTag: v2026.5.16 +hermesCommit: a91a57fa5a13d516c38b07a141a9ce8a3daabeb0 osCoverage: - linux - macos @@ -13,7 +13,7 @@ recommended: false # `qwen/qwen3-235b-a22b-instruct-2507-fp8` This record defines the checked-in allowlist entry consumed by the shipped -runtime for the pinned Hermes release. +runtime for the latest-only Hermes release contract. ## Sanitized Config Shape @@ -38,9 +38,9 @@ qualification workflow and summarized by this checked-in allowlist record. ## Streaming Turn Saved streaming qualification evidence is tracked in the same release -qualification workflow for the pinned Hermes release. +qualification workflow for the latest-only Hermes release. ## Harmless Tool-Use Turn Saved harmless tool-use qualification evidence is tracked in the same release -qualification workflow for the pinned Hermes release. +qualification workflow for the latest-only Hermes release. diff --git a/docs/release-readiness/hermes-agent-setup-v1.md b/docs/release-readiness/hermes-agent-setup-v1.md index 7d57c3e..74aabf9 100644 --- a/docs/release-readiness/hermes-agent-setup-v1.md +++ b/docs/release-readiness/hermes-agent-setup-v1.md @@ -9,7 +9,8 @@ for the v1 Hermes contract: - installed bin: `hermes-agent-setup` - canonical integration path: `provider: custom` - canonical base URL: `https://api.gonkagate.com/v1` -- pinned Hermes release for qualification artifacts: `v2026.4.13` +- latest-only Hermes floor and qualification baseline: `v2026.5.16` / + `v0.14.0` The current checked-in allowlist includes these artifact-backed models: @@ -30,8 +31,8 @@ Maintainer tooling for new or refreshed qualification evidence lives under: Current checked-in artifacts: -- `docs/launch-qualification/hermes-agent-setup/v2026.4.13/moonshotai-kimi-k2-6.md` -- `docs/launch-qualification/hermes-agent-setup/v2026.4.13/qwen-qwen3-235b-a22b-instruct-2507-fp8.md` +- `docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md` +- `docs/launch-qualification/hermes-agent-setup/v2026.5.16/qwen-qwen3-235b-a22b-instruct-2507-fp8.md` ## FR Coverage Map @@ -41,13 +42,19 @@ Launch Readiness section of the PRD: - FR0-FR3: public entrypoint, Node floor, platform guardrails, Hermes path resolution, and minimal managed config surface are implemented in `src/cli/`, `src/runtime/`, `src/hermes/`, and the CLI/runtime tests. -- FR4-FR7: shared-key, `OPENAI_BASE_URL`, matching provider, auth-pool, - normalized-read, and review-plan behavior are implemented in `src/hermes/`, - `src/planning/`, `src/ui/`, and the conflict-classification tests. +- FR4-FR7 plus the latest-only adaptation: shared-key, matching provider, + auth-pool, normalized-read, Hermes version floor, and review-plan behavior + are implemented in `src/hermes/`, `src/runtime/`, `src/planning/`, + `src/ui/`, and the conflict-classification tests. Legacy endpoint paths such + as `OPENAI_BASE_URL` are no longer managed or cleaned by the helper. - FR8-FR9: live catalog access, artifact-backed model qualification, hidden key prompt, model picker, config/env write planning, backups, rollback, and consolidated review are implemented in `src/gonkagate/`, `src/ui/`, `src/writes/`, `src/io/`, and the phase-three/phase-four/e2e tests. +- The catalog proof covers the canonical `GET /v1/models` URL, Bearer auth, + response-shape validation, retryable 5xx/429 handling, quota-shaped terminal + failures, checked-in qualification intersection, ignoring unqualified live + entries, and pre-write aborts before Hermes file mutation. - FR10 and Launch Readiness: checked-in launch qualification artifacts, validation tooling, public docs, package/CLI truthfulness, mirrored skill sync, and contract tests now describe the shipped helper rather than a @@ -58,8 +65,8 @@ Launch Readiness section of the PRD: - `npm run ci` - `npm pack --dry-run` - `npm run qualification:artifact:validate` -- confirm the current checked-in allowlist still matches the pinned Hermes - release and live GonkaGate catalog +- confirm the current checked-in allowlist still matches the latest-only Hermes + baseline and live GonkaGate catalog - confirm Linux, macOS, and WSL2 evidence is recorded in the artifact or that an explicit signed-off exception exists before GA - confirm `README.md`, `AGENTS.md`, `docs/`, `package.json`, diff --git a/docs/security.md b/docs/security.md index 8ab8e19..ed19f82 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,9 +24,8 @@ The helper writes only the minimum GonkaGate-managed surface: - `model.default` - `OPENAI_API_KEY` -Conflict-only cleanup is limited to the PRD-approved surfaces such as -`model.api_key`, `model.api`, incompatible `model.api_mode`, and one matching -provider entry when that scrub stays inside the allowed field set. +Conflict-only cleanup is limited to current model-owned surfaces: +`model.api_key`, `model.api`, and incompatible `model.api_mode`. Write safety rules: @@ -41,14 +40,15 @@ Write safety rules: The shipped runtime treats these as active security or correctness surfaces: - shared `OPENAI_API_KEY` consumers -- file-backed and inherited-process `OPENAI_BASE_URL` -- matching `custom_providers` / `providers:` entries that point at the - canonical GonkaGate URL +- current `providers:` entries with competing selectors for the canonical + GonkaGate URL +- legacy `custom_providers` entries that still point at the canonical + GonkaGate URL - matching `auth.json` credential pools under `credential_pool["custom:*"]` -The helper may scrub one matching provider entry after consolidated review, but -it does not mutate `auth.json` credential pools in v1. Matching credential -pools remain a blocking manual-resolution case with Hermes-owned follow-up. +The helper does not scrub provider registries or mutate `auth.json` credential +pools in v1. These remain blocking manual-resolution cases with Hermes-owned +follow-up. ## Qualification And Verification Limits @@ -73,5 +73,7 @@ The helper does not take ownership of: - shell profile mutation - arbitrary custom provider management - arbitrary custom base URLs +- legacy endpoint paths such as `OPENAI_BASE_URL`, `LLM_MODEL`, root-level + `provider` / `base_url`, and legacy `custom_providers` - repository-local `.env` files - direct mutation of `auth.json` diff --git a/docs/specs/hermes-latest-contract-adaptation/spec.md b/docs/specs/hermes-latest-contract-adaptation/spec.md new file mode 100644 index 0000000..f222f35 --- /dev/null +++ b/docs/specs/hermes-latest-contract-adaptation/spec.md @@ -0,0 +1,67 @@ +# Hermes Latest Contract Adaptation + +Status: draft +Last updated: 2026-05-24 +Target upstream release: Hermes Agent `v2026.5.16` / Hermes `v0.14.0` + +## Purpose + +Adapt `@gonkagate/hermes-agent-setup` from a release-pinned compatibility +helper into a latest-only onboarding helper for the current Hermes config +contract. + +The helper remains narrow: it configures GonkaGate as the primary +OpenAI-compatible Hermes endpoint. It must not become a legacy Hermes config +migration tool. + +## Decisions + +- The minimum supported Hermes release is `v2026.5.16` / `0.14.0`. +- Older Hermes versions fail during preflight before secret prompts, catalog + requests, or file writes. +- The supported endpoint contract is `config.yaml` `model.provider`, + `model.base_url`, and `model.default`. +- The supported secret contract is `.env` `OPENAI_API_KEY`. +- `OPENAI_BASE_URL`, `LLM_MODEL`, root-level `provider` / `base_url`, and + legacy `custom_providers` are not supported configuration paths for this + helper. +- The helper must not clean, rewrite, block on, or present review items for + `OPENAI_BASE_URL`. +- Current Hermes-owned surfaces that can still compete with the helper-managed + endpoint remain safety checks: `model.api_key`, `model.api`, + incompatible `model.api_mode`, matching auth pools, cron jobs with direct + `base_url`, and shared `OPENAI_API_KEY` reuse surfaces. +- Launch qualification must be refreshed against `v2026.5.16` before release + docs or model artifacts claim that baseline. + +## Non-Goals + +- Supporting Hermes releases older than `v2026.5.16`. +- Migrating root-level Hermes provider fields into `model.*`. +- Repairing or deleting legacy `OPENAI_BASE_URL` values from user files or the + inherited shell environment. +- Automatically migrating or scrubbing legacy provider registries. +- Expanding v1 runtime verification beyond the existing bounded + GonkaGate catalog and launch-qualification contract. + +## Implementation Slices + +1. Add a Hermes version floor in preflight. +2. Remove `OPENAI_BASE_URL` from conflict classification, review planning, + write planning, success text, and tests. +3. Tighten provider-registry handling so the helper does not silently migrate + legacy provider entries. +4. Refresh launch qualification artifacts for `v2026.5.16`. +5. Reconcile public docs, release-readiness notes, and mirrored contributor + guidance after runtime and qualification evidence are complete. + +## Acceptance Criteria + +- Running the helper with Hermes below `0.14.0` fails before prompting for a + GonkaGate key. +- Running the helper with file-backed or inherited `OPENAI_BASE_URL` does not + create blocking findings, confirmation items, advisories, or env cleanup. +- The helper still writes only `model.provider`, `model.base_url`, + `model.default`, and `.env` `OPENAI_API_KEY` for the primary onboarding path. +- `npm run ci` passes after code, tests, docs, and qualification metadata are + reconciled. diff --git a/src/commands/phase-four.ts b/src/commands/phase-four.ts index f80d52d..604789d 100644 --- a/src/commands/phase-four.ts +++ b/src/commands/phase-four.ts @@ -47,7 +47,6 @@ export function buildPhaseFourWritePlan(selection: PhaseThreeSelectionReady): const envPlan = buildEnvMutationPlan({ apiKey: selection.apiKey, - plannedEnvCleanup: selection.reviewPlan.plan.plannedEnvCleanup, read: selection.reviewPlan.read, }); const review = createPhaseFourReview({ diff --git a/src/constants/contract.ts b/src/constants/contract.ts index ba715b7..910fa52 100644 --- a/src/constants/contract.ts +++ b/src/constants/contract.ts @@ -9,13 +9,15 @@ export const CONTRACT_METADATA = { publicEntrypoint: "npx @gonkagate/hermes-agent-setup", prdPath: "docs/specs/hermes-agent-setup-prd/spec.md", canonicalBaseUrl: "https://api.gonkagate.com/v1", - pinnedHermesReleaseTag: "v2026.4.13", - pinnedHermesVersion: "v0.9.0", + minimumHermesReleaseTag: "v2026.5.16", + minimumHermesVersion: "0.14.0", + pinnedHermesReleaseTag: "v2026.5.16", + pinnedHermesVersion: "v0.14.0", launchQualificationArtifactRoot: "docs/launch-qualification/hermes-agent-setup", nodeFloor: ">=22.14.0", runtimePublicState: - "The onboarding runtime is implemented: the CLI resolves the active Hermes context, prompts for a hidden GonkaGate key, intersects the live /v1/models catalog with checked-in launch qualification artifacts, plans conflict cleanup, writes config.yaml before .env with backups and rollback, and prints a final summary without claiming end-to-end billing readiness.", + "The onboarding runtime is implemented: the CLI resolves the active Hermes context, enforces a latest-only Hermes floor, prompts for a hidden GonkaGate key, intersects the live /v1/models catalog with checked-in launch qualification artifacts, handles current conflict surfaces without migrating legacy endpoint paths, writes config.yaml before .env with backups and rollback, and prints a final summary without claiming end-to-end billing readiness.", supportedPlatforms: ["linux", "macos", "wsl2"] as const, explicitlyUnsupportedPlatforms: ["win32", "android", "termux"] as const, helperManagedConfigKeys: [ diff --git a/src/domain/conflicts.ts b/src/domain/conflicts.ts index 7755d2b..a7a1a3b 100644 --- a/src/domain/conflicts.ts +++ b/src/domain/conflicts.ts @@ -30,32 +30,8 @@ export interface SharedOpenAiKeyConflict { jobName?: string; } -export interface OpenAiBaseUrlConflict { - kind: "openai_base_url"; - source: "file" | "inherited_process"; - status: "advisory" | "blocking" | "confirmation_required" | "planned_cleanup"; - value: string; - canonicalValue: string; - resolution: - | "clear_file_value" - | "clear_file_value_without_confirmation" - | "unset_shell_and_rerun" - | "warn_same_shell_runtime"; -} - -export type MatchingProviderScrubField = - | "api" - | "api_key" - | "api_key_env" - | "api_mode" - | "base_url_alias" - | "key_env" - | "transport" - | "url"; - export interface MatchingProviderMatch { entry: NormalizedNamedCustomProviderEntry; - scrubFields: readonly MatchingProviderScrubField[]; } export type MatchingProviderConflict = @@ -69,15 +45,13 @@ export type MatchingProviderConflict = matchingEntries: readonly MatchingProviderMatch[]; status: "compatible"; } - | { - kind: "matching_provider"; - matchingEntries: readonly [MatchingProviderMatch]; - status: "scrubbable"; - } | { kind: "matching_provider"; matchingEntries: readonly MatchingProviderMatch[]; - reason: "multiple_matching_entries"; + reason: + | "competing_provider_selectors" + | "legacy_custom_provider_entry" + | "multiple_matching_entries"; status: "blocking"; }; @@ -102,40 +76,18 @@ export interface PlannedConfigScrub { providerName?: string; } -export interface PlannedEnvCleanup { - confirmationRequired: boolean; - existingValue: string; - key: "OPENAI_BASE_URL"; - reason: string; - source: "file"; -} - export type PreWriteReviewBlockingFinding = | AuthPoolConflict - | OpenAiBaseUrlConflict | SharedOpenAiKeyConflict | Extract; -export type PreWriteReviewConfirmationItem = - | { - conflicts: readonly SharedOpenAiKeyConflict[]; - kind: "shared_openai_key_takeover"; - } - | { - conflict: OpenAiBaseUrlConflict; - kind: "file_openai_base_url_cleanup"; - } - | { - conflict: Extract; - kind: "matching_provider_scrub"; - }; - -export type PreWriteReviewAdvisory = OpenAiBaseUrlConflict; +export type PreWriteReviewConfirmationItem = { + conflicts: readonly SharedOpenAiKeyConflict[]; + kind: "shared_openai_key_takeover"; +}; export interface PreWriteReviewPlan { - advisories: readonly PreWriteReviewAdvisory[]; blockingFindings: readonly PreWriteReviewBlockingFinding[]; confirmationItems: readonly PreWriteReviewConfirmationItem[]; plannedConfigScrubs: readonly PlannedConfigScrub[]; - plannedEnvCleanup: readonly PlannedEnvCleanup[]; } diff --git a/src/domain/runtime.ts b/src/domain/runtime.ts index 1e534d7..2bd6ffe 100644 --- a/src/domain/runtime.ts +++ b/src/domain/runtime.ts @@ -14,6 +14,7 @@ export type OnboardFailureCode = | "missing_tty" | "unsupported_platform" | "hermes_not_found" + | "unsupported_hermes_version" | "managed_install" | "write_blocked" | "path_resolution_failed" @@ -25,8 +26,6 @@ export type OnboardFailureCode = | "model_auth_conflict" | "provider_conflict" | "auth_pool_conflict" - | "inherited_base_url_conflict" - | "file_backed_base_url_conflict" | "api_key_invalid" | "qualified_models_unavailable" | "catalog_auth_failed" @@ -64,6 +63,7 @@ export interface ResolvedHermesContext { export interface PreflightReport extends ResolvedHermesContext { hermesCommand: "hermes"; + hermesVersion: string; nodeVersion: string; platform: (typeof CONTRACT_METADATA.supportedPlatforms)[number]; } @@ -109,6 +109,7 @@ export const ONBOARD_FAILURE_FAMILY_BY_CODE = { missing_tty: "runtime", unsupported_platform: "runtime", hermes_not_found: "runtime", + unsupported_hermes_version: "runtime", managed_install: "runtime", write_blocked: "runtime", path_resolution_failed: "runtime", @@ -120,8 +121,6 @@ export const ONBOARD_FAILURE_FAMILY_BY_CODE = { model_auth_conflict: "conflict", provider_conflict: "conflict", auth_pool_conflict: "conflict", - inherited_base_url_conflict: "conflict", - file_backed_base_url_conflict: "conflict", api_key_invalid: "catalog", qualified_models_unavailable: "catalog", catalog_auth_failed: "catalog", diff --git a/src/hermes/conflicts/matching-providers.ts b/src/hermes/conflicts/matching-providers.ts index 165a8b5..ddfe114 100644 --- a/src/hermes/conflicts/matching-providers.ts +++ b/src/hermes/conflicts/matching-providers.ts @@ -1,7 +1,6 @@ import type { MatchingProviderConflict, MatchingProviderMatch, - MatchingProviderScrubField, } from "../../domain/conflicts.js"; import type { NormalizedHermesRead } from "../normalized-read.js"; @@ -12,7 +11,6 @@ export function classifyMatchingProviders( .filter((entry) => entry.canonicalUrlFieldKeys.length > 0) .map((entry) => ({ entry, - scrubFields: collectScrubFields(entry), })); if (matchingEntries.length === 0) { @@ -34,7 +32,24 @@ export function classifyMatchingProviders( const [singleMatch] = matchingEntries; - if (singleMatch === undefined || singleMatch.scrubFields.length === 0) { + if (singleMatch === undefined) { + return { + kind: "matching_provider", + matchingEntries: [], + status: "none", + }; + } + + if (singleMatch.entry.sourceShape === "custom_providers") { + return { + kind: "matching_provider", + matchingEntries, + reason: "legacy_custom_provider_entry", + status: "blocking", + }; + } + + if (!hasCompetingProviderSelectors(singleMatch.entry)) { return { kind: "matching_provider", matchingEntries, @@ -44,53 +59,21 @@ export function classifyMatchingProviders( return { kind: "matching_provider", - matchingEntries: [singleMatch], - status: "scrubbable", + matchingEntries, + reason: "competing_provider_selectors", + status: "blocking", }; } -function collectScrubFields( +function hasCompetingProviderSelectors( entry: NormalizedHermesRead["namedCustomProviders"][number], -): readonly MatchingProviderScrubField[] { - const scrubFields: MatchingProviderScrubField[] = []; - - if (entry.apiKey.length > 0) { - scrubFields.push("api_key"); - } - - if (entry.rawEntry.api_key_env !== undefined) { - scrubFields.push("api_key_env"); - } - - if (entry.rawEntry.key_env !== undefined) { - scrubFields.push("key_env"); - } - - if (entry.apiMode.length > 0 && entry.apiMode !== "chat_completions") { - scrubFields.push("api_mode"); - } - - if (entry.sourceShape === "providers") { - if (entry.transport.length > 0 && entry.transport !== "openai_chat") { - scrubFields.push("transport"); - } - } - - for (const fieldKey of entry.nonCanonicalUrlFieldKeys) { - switch (fieldKey) { - case "api": - scrubFields.push("api"); - break; - case "url": - scrubFields.push("url"); - break; - case "base_url": - scrubFields.push("base_url_alias"); - break; - default: - break; - } - } - - return [...new Set(scrubFields)]; +): boolean { + return ( + entry.apiKey.length > 0 || + entry.rawEntry.api_key_env !== undefined || + entry.rawEntry.key_env !== undefined || + (entry.apiMode.length > 0 && entry.apiMode !== "chat_completions") || + (entry.transport.length > 0 && entry.transport !== "openai_chat") || + entry.nonCanonicalUrlFieldKeys.length > 0 + ); } diff --git a/src/hermes/conflicts/openai-base-url.ts b/src/hermes/conflicts/openai-base-url.ts deleted file mode 100644 index 6514e34..0000000 --- a/src/hermes/conflicts/openai-base-url.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { CANONICAL_BASE_URL } from "../../constants/contract.js"; -import type { OpenAiBaseUrlConflict } from "../../domain/conflicts.js"; -import type { NormalizedHermesRead } from "../normalized-read.js"; -import { canonicalizeBaseUrl } from "../provider-utils.js"; - -export function classifyOpenAiBaseUrlConflicts( - read: NormalizedHermesRead, -): readonly OpenAiBaseUrlConflict[] { - const conflicts: OpenAiBaseUrlConflict[] = []; - const fileValue = normalizeEnvValue(read.env.file.OPENAI_BASE_URL); - const inheritedValue = normalizeEnvValue( - read.env.inheritedProcess.OPENAI_BASE_URL, - ); - - if (fileValue.length > 0) { - conflicts.push( - canonicalizeBaseUrl(fileValue) === CANONICAL_BASE_URL - ? { - canonicalValue: CANONICAL_BASE_URL, - kind: "openai_base_url", - resolution: "clear_file_value_without_confirmation", - source: "file", - status: "planned_cleanup", - value: fileValue, - } - : { - canonicalValue: CANONICAL_BASE_URL, - kind: "openai_base_url", - resolution: "clear_file_value", - source: "file", - status: "confirmation_required", - value: fileValue, - }, - ); - } - - if (inheritedValue.length > 0) { - conflicts.push( - canonicalizeBaseUrl(inheritedValue) === CANONICAL_BASE_URL - ? { - canonicalValue: CANONICAL_BASE_URL, - kind: "openai_base_url", - resolution: "warn_same_shell_runtime", - source: "inherited_process", - status: "advisory", - value: inheritedValue, - } - : { - canonicalValue: CANONICAL_BASE_URL, - kind: "openai_base_url", - resolution: "unset_shell_and_rerun", - source: "inherited_process", - status: "blocking", - value: inheritedValue, - }, - ); - } - - return conflicts; -} - -function normalizeEnvValue(value: string | undefined): string { - return typeof value === "string" ? value.trim() : ""; -} diff --git a/src/hermes/normalized-read.ts b/src/hermes/normalized-read.ts index cb5ce6e..e0f7678 100644 --- a/src/hermes/normalized-read.ts +++ b/src/hermes/normalized-read.ts @@ -230,9 +230,10 @@ export async function loadNormalizedHermesRead( }; } - const normalizedRoot = migrateLegacyModelFields( - expandConfigValue(rawRoot, mergedRuntimeVisibleEnv), - ); + const normalizedRoot = expandConfigValue( + rawRoot, + mergedRuntimeVisibleEnv, + ) as Record; return { ok: true, @@ -326,32 +327,6 @@ function expandConfigValue( ); } -function migrateLegacyModelFields(root: unknown): Record { - const safeRoot = isRecord(root) ? { ...root } : {}; - const modelValue = safeRoot.model; - const migratedModel = isRecord(modelValue) ? { ...modelValue } : {}; - const legacyProvider = normalizeStringValue(safeRoot.provider); - const legacyBaseUrl = normalizeStringValue(safeRoot.base_url); - - if ( - normalizeStringValue(migratedModel.provider).length === 0 && - legacyProvider.length > 0 - ) { - migratedModel.provider = legacyProvider; - } - - if ( - normalizeStringValue(migratedModel.base_url).length === 0 && - legacyBaseUrl.length > 0 - ) { - migratedModel.base_url = legacyBaseUrl; - } - - safeRoot.model = migratedModel; - - return safeRoot; -} - function normalizeModel( root: Readonly>, ): NormalizedHermesModelConfig { diff --git a/src/planning/blocking-failures.ts b/src/planning/blocking-failures.ts index a63ec82..9fdd3a5 100644 --- a/src/planning/blocking-failures.ts +++ b/src/planning/blocking-failures.ts @@ -4,7 +4,6 @@ import { } from "../domain/runtime.js"; import type { AuthPoolConflict, - OpenAiBaseUrlConflict, PreWriteReviewBlockingFinding, SharedOpenAiKeyConflict, } from "../domain/conflicts.js"; @@ -29,14 +28,13 @@ export function createPreWriteBlockingFailure( matchingEntries: blockingFinding.matchingEntries.map( (entry) => entry.entry.name, ), + reason: blockingFinding.reason, }, guidance: - "Remove the duplicate GonkaGate provider entries from Hermes config.yaml, then rerun the helper.", + "Remove or repair the matching GonkaGate custom-provider entries in Hermes config.yaml, then rerun the helper.", message: - "Multiple on-disk custom-provider entries still target the canonical GonkaGate URL.", + "A Hermes custom-provider entry still targets the canonical GonkaGate URL outside the helper-managed model config.", }); - case "openai_base_url": - return createOpenAiBaseUrlFailure(blockingFinding); case "shared_openai_key": return createSharedKeyFailure(blockingFinding); default: @@ -60,21 +58,6 @@ function createAuthPoolFailure( }); } -function createOpenAiBaseUrlFailure( - conflict: OpenAiBaseUrlConflict, -): OnboardFailure { - return createOnboardFailure("inherited_base_url_conflict", { - details: { - source: conflict.source, - value: conflict.value, - }, - guidance: - "Unset OPENAI_BASE_URL in the current shell or start a fresh shell session, then rerun the helper.", - message: - "A non-canonical inherited OPENAI_BASE_URL is still active in the current shell session.", - }); -} - function createSharedKeyFailure( conflict: SharedOpenAiKeyConflict, ): OnboardFailure { diff --git a/src/planning/review-plan-builder.ts b/src/planning/review-plan-builder.ts index 7e5c9c6..14c7b1b 100644 --- a/src/planning/review-plan-builder.ts +++ b/src/planning/review-plan-builder.ts @@ -1,12 +1,10 @@ import type { MatchingProviderConflict, PlannedConfigScrub, - PlannedEnvCleanup, PreWriteReviewPlan, } from "../domain/conflicts.js"; import { classifyAuthPoolConflict } from "../hermes/conflicts/auth-pools.js"; import { classifyMatchingProviders } from "../hermes/conflicts/matching-providers.js"; -import { classifyOpenAiBaseUrlConflicts } from "../hermes/conflicts/openai-base-url.js"; import { classifySharedOpenAiKeyConflicts } from "../hermes/conflicts/shared-openai-key.js"; import type { LoadNormalizedHermesReadResult, @@ -19,7 +17,6 @@ import type { OnboardDependencies } from "../runtime/dependencies.js"; export interface BuildPreWriteReviewPlanResult { authPoolConflict: ReturnType; matchingProviderConflict: MatchingProviderConflict; - openAiBaseUrlConflicts: ReturnType; plan: PreWriteReviewPlan; read: NormalizedHermesRead; sharedOpenAiKeyConflicts: ReturnType; @@ -52,25 +49,17 @@ export function buildPreWriteReviewPlan( read: NormalizedHermesRead, ): BuildPreWriteReviewPlanResult { const sharedOpenAiKeyConflicts = classifySharedOpenAiKeyConflicts(read); - const openAiBaseUrlConflicts = classifyOpenAiBaseUrlConflicts(read); const matchingProviderConflict = classifyMatchingProviders(read); const authPoolConflict = classifyAuthPoolConflict( read, matchingProviderConflict, ); - const plannedConfigScrubs = [ - ...collectModelScrubs(read), - ...collectMatchingProviderScrubs(matchingProviderConflict), - ]; - const plannedEnvCleanup = collectEnvCleanup(openAiBaseUrlConflicts); + const plannedConfigScrubs = [...collectModelScrubs(read)]; const blockingFindings = [ ...sharedOpenAiKeyConflicts.filter( (conflict) => conflict.status === "blocking", ), - ...openAiBaseUrlConflicts.filter( - (conflict) => conflict.status === "blocking", - ), ...(matchingProviderConflict.status === "blocking" ? [matchingProviderConflict] : []), @@ -89,34 +78,15 @@ export function buildPreWriteReviewPlan( }, ] : []), - ...openAiBaseUrlConflicts - .filter((conflict) => conflict.status === "confirmation_required") - .map((conflict) => ({ - conflict, - kind: "file_openai_base_url_cleanup" as const, - })), - ...(matchingProviderConflict.status === "scrubbable" - ? [ - { - conflict: matchingProviderConflict, - kind: "matching_provider_scrub" as const, - }, - ] - : []), ]; return { authPoolConflict, matchingProviderConflict, - openAiBaseUrlConflicts, plan: { - advisories: openAiBaseUrlConflicts.filter( - (conflict) => conflict.status === "advisory", - ), blockingFindings, confirmationItems, plannedConfigScrubs, - plannedEnvCleanup, }, read, sharedOpenAiKeyConflicts, @@ -161,125 +131,3 @@ function collectModelScrubs( return scrubs; } - -function collectMatchingProviderScrubs( - conflict: MatchingProviderConflict, -): readonly PlannedConfigScrub[] { - if (conflict.status !== "scrubbable") { - return []; - } - - const [match] = conflict.matchingEntries; - - if (match === undefined) { - return []; - } - - const scrubs: PlannedConfigScrub[] = []; - const { entry } = match; - - if (entry.apiKey.length > 0) { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, "api_key"], - entry.name, - `${entry.path}.api_key`, - "Clear competing inline API key.", - ), - ); - } - - if (entry.rawEntry.api_key_env !== undefined) { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, "api_key_env"], - entry.name, - `${entry.path}.api_key_env`, - "Clear competing provider-specific secret binding.", - ), - ); - } - - if (entry.rawEntry.key_env !== undefined) { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, "key_env"], - entry.name, - `${entry.path}.key_env`, - "Clear competing provider-specific secret binding.", - ), - ); - } - - if (entry.apiMode.length > 0 && entry.apiMode !== "chat_completions") { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, "api_mode"], - entry.name, - `${entry.path}.api_mode`, - "Clear incompatible provider api_mode.", - ), - ); - } - - if (entry.transport.length > 0 && entry.transport !== "openai_chat") { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, "transport"], - entry.name, - `${entry.path}.transport`, - "Clear incompatible provider transport.", - ), - ); - } - - for (const fieldKey of entry.nonCanonicalUrlFieldKeys) { - scrubs.push( - createProviderScrub( - [...entry.pathSegments, fieldKey], - entry.name, - `${entry.path}.${fieldKey}`, - "Clear duplicate non-canonical URL alias.", - ), - ); - } - - return scrubs; -} - -function createProviderScrub( - pathSegments: readonly (string | number)[], - providerName: string, - fieldPath: string, - reason: string, -): PlannedConfigScrub { - return { - fieldPath, - pathSegments, - providerName, - reason, - target: "named_provider", - }; -} - -function collectEnvCleanup( - conflicts: ReturnType, -): readonly PlannedEnvCleanup[] { - return conflicts - .filter( - (conflict) => - conflict.source === "file" && - (conflict.status === "planned_cleanup" || - conflict.status === "confirmation_required"), - ) - .map((conflict) => ({ - confirmationRequired: conflict.status === "confirmation_required", - existingValue: conflict.value, - key: "OPENAI_BASE_URL", - reason: - conflict.status === "planned_cleanup" - ? "Clear canonical OPENAI_BASE_URL residue so config.yaml is the saved source of truth." - : "Clear conflicting OPENAI_BASE_URL before helper-managed onboarding can be deterministic.", - source: "file", - })); -} diff --git a/src/runtime/preconditions.ts b/src/runtime/preconditions.ts index 3eac8d7..25923b3 100644 --- a/src/runtime/preconditions.ts +++ b/src/runtime/preconditions.ts @@ -63,6 +63,24 @@ export async function runPreflightChecks( }); } + const hermesVersionCheck = validateHermesVersion(hermesPresence.stdout); + + if (!hermesVersionCheck.ok) { + return createOnboardFailure("unsupported_hermes_version", { + details: { + minimumReleaseTag: CONTRACT_METADATA.minimumHermesReleaseTag, + minimumVersion: CONTRACT_METADATA.minimumHermesVersion, + reason: hermesVersionCheck.reason, + versionOutput: hermesPresence.stdout, + }, + guidance: `Upgrade Hermes Agent to ${CONTRACT_METADATA.minimumHermesReleaseTag} / ${CONTRACT_METADATA.minimumHermesVersion} or newer, then rerun ${CONTRACT_METADATA.publicEntrypoint}.`, + message: + hermesVersionCheck.reason === "unparseable" + ? "Hermes returned a version string that the helper cannot validate against the supported latest-only contract." + : `Hermes Agent ${hermesVersionCheck.version} is below the supported floor ${CONTRACT_METADATA.minimumHermesVersion} (${CONTRACT_METADATA.minimumHermesReleaseTag}).`, + }); + } + const contextResult = await resolveHermesContext(options, dependencies); if (!contextResult.ok) { @@ -106,6 +124,7 @@ export async function runPreflightChecks( const preflight: PreflightReport = { ...contextResult.context, hermesCommand: "hermes", + hermesVersion: hermesVersionCheck.version, nodeVersion: dependencies.runtime.nodeVersion, platform: supportedPlatform.platform, }; @@ -118,6 +137,123 @@ export async function runPreflightChecks( }; } +type HermesVersionCheckResult = + | { + ok: true; + version: string; + } + | { + ok: false; + reason: "below_minimum" | "unparseable"; + version?: string; + }; + +function validateHermesVersion(stdout: string): HermesVersionCheckResult { + const parsedSemver = parseHermesSemver(stdout); + + if (parsedSemver !== undefined) { + return semver.gte(parsedSemver, CONTRACT_METADATA.minimumHermesVersion) + ? { + ok: true, + version: parsedSemver, + } + : { + ok: false, + reason: "below_minimum", + version: parsedSemver, + }; + } + + const parsedReleaseTag = parseHermesReleaseTag(stdout); + + if (parsedReleaseTag !== undefined) { + return compareReleaseTags( + parsedReleaseTag, + CONTRACT_METADATA.minimumHermesReleaseTag, + ) >= 0 + ? { + ok: true, + version: parsedReleaseTag, + } + : { + ok: false, + reason: "below_minimum", + version: parsedReleaseTag, + }; + } + + return { + ok: false, + reason: "unparseable", + }; +} + +function parseHermesSemver(stdout: string): string | undefined { + const versionMatches = stdout.matchAll( + /(?:^|[\s(])v?(\d+\.\d+\.\d+)(?=$|[\s),])/gu, + ); + + for (const match of versionMatches) { + const candidate = match[1]; + + if (candidate === undefined) { + continue; + } + + const [majorText] = candidate.split("."); + const major = Number(majorText); + + if (!Number.isFinite(major) || major >= 1000) { + continue; + } + + const cleanedVersion = semver.clean(candidate); + + if (cleanedVersion !== null) { + return cleanedVersion; + } + } + + return undefined; +} + +function parseHermesReleaseTag(stdout: string): string | undefined { + const match = stdout.match( + /(?:^|[\s(])v?(20\d{2})\.(\d{1,2})\.(\d{1,2})(?=$|[\s),])/u, + ); + + if (match === null) { + return undefined; + } + + return `v${match[1]}.${Number(match[2])}.${Number(match[3])}`; +} + +function compareReleaseTags(left: string, right: string): number { + const leftParts = parseReleaseTagParts(left); + const rightParts = parseReleaseTagParts(right); + + for (let index = 0; index < leftParts.length; index += 1) { + const difference = leftParts[index] - rightParts[index]; + + if (difference !== 0) { + return difference; + } + } + + return 0; +} + +function parseReleaseTagParts(tag: string): readonly [number, number, number] { + const match = tag.match(/^v(20\d{2})\.(\d{1,2})\.(\d{1,2})$/u); + + if (match === null) { + return [0, 0, 0]; + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + async function detectManagedInstall( homeDir: string, dependencies: OnboardDependencies, diff --git a/src/ui/review.ts b/src/ui/review.ts index 70b184a..6f9ace1 100644 --- a/src/ui/review.ts +++ b/src/ui/review.ts @@ -1,7 +1,4 @@ -import type { - MatchingProviderScrubField, - PreWriteReviewPlan, -} from "../domain/conflicts.js"; +import type { PreWriteReviewPlan } from "../domain/conflicts.js"; import type { ConfigMutationPlan, EnvMutationPlan, @@ -31,7 +28,6 @@ export function createPhaseFourReview( ...renderConfigChanges(input.configPlan), ...renderEnvChanges(input.envPlan), ...renderConfirmationItems(input.reviewPlan), - ...renderAdvisories(input.reviewPlan), "", ]; @@ -91,24 +87,6 @@ function renderConfirmationItems( ), ); break; - case "file_openai_base_url_cleanup": - lines.push( - `- Clear file-backed OPENAI_BASE_URL=${item.conflict.value}`, - ); - break; - case "matching_provider_scrub": { - const [match] = item.conflict.matchingEntries; - - if (match !== undefined) { - lines.push( - `- Scrub matching provider "${match.entry.name}" fields: ${match.scrubFields - .map(formatScrubField) - .join(", ")}`, - ); - } - - break; - } default: break; } @@ -116,26 +94,3 @@ function renderConfirmationItems( return lines; } - -function renderAdvisories(reviewPlan: PreWriteReviewPlan): readonly string[] { - if (reviewPlan.advisories.length === 0) { - return []; - } - - return [ - "Advisories:", - ...reviewPlan.advisories.map( - (advisory) => - `- ${advisory.source === "inherited_process" ? "Shell-owned" : "File-backed"} OPENAI_BASE_URL remains visible as ${advisory.value}`, - ), - ]; -} - -function formatScrubField(field: MatchingProviderScrubField): string { - switch (field) { - case "base_url_alias": - return "base_url"; - default: - return field; - } -} diff --git a/src/ui/success.ts b/src/ui/success.ts index 7087395..91342ca 100644 --- a/src/ui/success.ts +++ b/src/ui/success.ts @@ -17,7 +17,6 @@ export function renderOnboardSuccess(result: OnboardSuccessResult): string { `- model.default = ${result.selectedModelId}`, "Applied file changes:", ...renderAppliedChanges(result), - ...renderAdvisories(result), "Next steps:", "- Run `hermes` in this resolved context to start using the configured GonkaGate model.", "- The live `/v1/models` check confirmed auth and catalog visibility only. It did not verify billing/quota for the first billable request or full Hermes runtime readiness.", @@ -55,20 +54,6 @@ function renderAppliedChanges(result: OnboardSuccessResult): readonly string[] { : ["- No cleanup beyond the managed GonkaGate settings was required."]; } -function renderAdvisories(result: OnboardSuccessResult): readonly string[] { - if (result.reviewPlan.advisories.length === 0) { - return []; - } - - return [ - "Advisories:", - ...result.reviewPlan.advisories.map( - (advisory) => - `- ${advisory.source === "inherited_process" ? "Shell-owned" : "File-backed"} OPENAI_BASE_URL remains visible as ${advisory.value}`, - ), - ]; -} - function formatResolvedContext(result: PreflightReport): string { if (result.profileMode === "explicit_profile") { return `Resolved Hermes context: profile "${result.profileName ?? "unknown"}"`; diff --git a/src/writes/env-plan.ts b/src/writes/env-plan.ts index 2205cd1..5471ac9 100644 --- a/src/writes/env-plan.ts +++ b/src/writes/env-plan.ts @@ -1,11 +1,9 @@ -import type { PlannedEnvCleanup } from "../domain/conflicts.js"; import type { EnvMutationAction, EnvMutationPlan } from "../domain/writes.js"; import type { NormalizedHermesRead } from "../hermes/normalized-read.js"; import type { ValidatedApiKey } from "../validation/api-key.js"; export interface BuildEnvMutationPlanInput { apiKey: ValidatedApiKey; - plannedEnvCleanup: readonly PlannedEnvCleanup[]; read: NormalizedHermesRead; } @@ -36,18 +34,6 @@ export function buildEnvMutationPlan( orderedKeys.push("OPENAI_API_KEY"); } - for (const cleanup of input.plannedEnvCleanup) { - if (!(cleanup.key in currentValues)) { - continue; - } - - delete currentValues[cleanup.key]; - actions.push({ - key: cleanup.key, - kind: "delete", - }); - } - const nextOrderedKeys = orderedKeys.filter((key) => key in currentValues); const changed = actions.length > 0; diff --git a/test/cli.test.ts b/test/cli.test.ts index b960332..dce394e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -47,7 +47,7 @@ test("CLI help renders the shipped helper contract surface", () => { assert.match(result.stdout, /The onboarding runtime is implemented/i); assert.match( result.stdout, - /docs\/launch-qualification\/hermes-agent-setup\/v2026\.4\.13/, + /docs\/launch-qualification\/hermes-agent-setup\/v2026\.5\.16/, ); assert.match(result.stdout, /https:\/\/api\.gonkagate\.com\/v1/); }); diff --git a/test/config-plan.test.ts b/test/config-plan.test.ts index 4950d89..823eb15 100644 --- a/test/config-plan.test.ts +++ b/test/config-plan.test.ts @@ -145,7 +145,7 @@ test("config planner leaves legacy root provider/base_url keys untouched while w } }); -test("config planner scrubs only the allowed fields from one matching providers: entry", async () => { +test("config planner leaves provider registry entries untouched", async () => { const harness = await createHermesIntegrationHarness({ fixture: "providers-dict-match", }); @@ -184,6 +184,9 @@ test("config planner scrubs only the allowed fields from one matching providers: assert.deepEqual(providers.gonkagate, { api: "https://api.gonkagate.com/v1", + api_key: "inline-provider-key", + api_mode: "codex_responses", + transport: "responses", }); } finally { await harness.cleanup(); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index e5962f7..a9ad4f6 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -16,9 +16,10 @@ test("required docs files exist", () => { "docs/how-it-works.md", "docs/security.md", "docs/launch-qualification/hermes-agent-setup/README.md", - "docs/launch-qualification/hermes-agent-setup/v2026.4.13/moonshotai-kimi-k2-6.md", - "docs/launch-qualification/hermes-agent-setup/v2026.4.13/qwen-qwen3-235b-a22b-instruct-2507-fp8.md", + "docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md", + "docs/launch-qualification/hermes-agent-setup/v2026.5.16/qwen-qwen3-235b-a22b-instruct-2507-fp8.md", "docs/release-readiness/hermes-agent-setup-v1.md", + "docs/specs/hermes-latest-contract-adaptation/spec.md", "docs/specs/hermes-agent-setup-prd/spec.md", ]; @@ -40,12 +41,18 @@ test("README captures the shipped helper contract", () => { assert.match(readme, /https:\/\/api\.gonkagate\.com\/v1/); assert.match(readme, /~\/\.hermes\/config\.yaml/); assert.match(readme, /~\/\.hermes\/\.env/); + assert.match(readme, /v2026\.5\.16/); + assert.match(readme, /OPENAI_BASE_URL/); assert.match(readme, /GET \/v1\/models/); assert.match(readme, /launch qualification artifacts/i); assert.match(readme, /moonshotai\/kimi-k2\.6/); assert.match(readme, /qwen\/qwen3-235b-a22b-instruct-2507-fp8/); assert.match(readme, /United States of America|U\.S\. territories/i); assert.match(readme, /docs\/specs\/hermes-agent-setup-prd\/spec\.md/); + assert.match( + readme, + /docs\/specs\/hermes-latest-contract-adaptation\/spec\.md/, + ); assert.doesNotMatch(readme, /Phase 1 preflight/i); assert.doesNotMatch(readme, /not shipped yet/i); }); @@ -55,6 +62,7 @@ test("AGENTS documents the shipped runtime truth and release workflow", () => { assert.match(agents, /end-to-end public onboarding runtime is implemented/i); assert.match(agents, /launch qualification artifacts exist/i); + assert.match(agents, /v2026\.5\.16/); assert.match(agents, /provider:\s*custom/); assert.match(agents, /https:\/\/api\.gonkagate\.com\/v1/); assert.match(agents, /Linux, macOS, and WSL2/i); @@ -91,18 +99,25 @@ test("implementation docs capture the shipped runtime, qualification, and securi assert.match(howItWorks, /runtime is implemented and shipped/i); assert.match(howItWorks, /auth\.json/i); assert.match(howItWorks, /custom_providers|providers:/i); + assert.match(howItWorks, /does\s+not scrub provider registries/i); + assert.match( + howItWorks, + /Legacy endpoint paths are not cleaned or\s+migrated/i, + ); assert.match( howItWorks, /write `?config\.yaml`? first, write `?\.env`? second/i, ); assert.match(howItWorks, /launch qualification artifacts/i); assert.match(howItWorks, /GET \/v1\/models/i); + assert.match(howItWorks, /live-only unqualified entries are not selectable/i); assert.doesNotMatch(howItWorks, /not implemented yet/i); assert.match(security, /hidden interactive\s+prompt/i); assert.match(security, /never write the key to `config\.yaml`/i); assert.match(security, /owner-only `?\.env`? permissions/i); - assert.match(security, /does not mutate `auth\.json` credential pools/i); + assert.match(security, /mutate `auth\.json` credential\s+pools/i); + assert.match(security, /does not scrub provider registries/i); assert.match(security, /docs\/launch-qualification\/hermes-agent-setup/i); assert.doesNotMatch(security, /Phase 1 preflight/i); }); @@ -115,6 +130,7 @@ test("docs index and release readiness label current versus historical surfaces ); assert.match(docsIndex, /Current Contract Documents/i); + assert.match(docsIndex, /Hermes Latest Contract Adaptation/i); assert.match(docsIndex, /Qualification And Release/i); assert.match(docsIndex, /Historical Context/i); assert.match(docsIndex, /historical execution record/i); diff --git a/test/e2e-onboard.test.ts b/test/e2e-onboard.test.ts index 9b4b324..86e0fb4 100644 --- a/test/e2e-onboard.test.ts +++ b/test/e2e-onboard.test.ts @@ -83,18 +83,30 @@ test("declining the consolidated confirmation cancels the public flow without to } }); -test("shell-owned non-canonical OPENAI_BASE_URL blocks before prompting for a secret", async () => { +test("shell-owned non-canonical OPENAI_BASE_URL no longer blocks latest-only flow", async () => { const harness = await createHermesIntegrationHarness({ fixture: "clean-home", }); + const server = await harness.startFakeModelsServer({ + responseBody: { + error: { + code: "insufficient_quota", + }, + }, + statusCode: 429, + }); const stdout = createBufferWriter(); const stderr = createBufferWriter(); try { await harness.installFakeHermesOnPath(); + harness.queueSecretPromptResponses("gp-e2e-secret"); const result = await run([], { dependencies: harness.createDependencies({ + http: { + fetch: server.createFetchOverride(), + }, runtime: { env: { OPENAI_BASE_URL: "https://api.other-provider.example/v1", @@ -111,10 +123,13 @@ test("shell-owned non-canonical OPENAI_BASE_URL blocks before prompting for a se assert.equal(result.exitCode, 1); assert.equal(result.result?.status, "failure"); - assert.equal(result.result?.code, "inherited_base_url_conflict"); - assert.match(stdout.contents, /Unset OPENAI_BASE_URL/i); - assert.deepEqual(harness.readPromptInvocations().readSecretMessages, []); + assert.equal(result.result?.code, "catalog_auth_failed"); + assert.doesNotMatch(stdout.contents, /Unset OPENAI_BASE_URL/i); + assert.deepEqual(harness.readPromptInvocations().readSecretMessages, [ + "Enter your GonkaGate API key", + ]); } finally { + await server.close(); await harness.cleanup(); } }); diff --git a/test/env-plan.test.ts b/test/env-plan.test.ts index 35f2157..10abaf0 100644 --- a/test/env-plan.test.ts +++ b/test/env-plan.test.ts @@ -42,7 +42,6 @@ test("env planner creates a new .env when the resolved file is absent", async () const plan = buildEnvMutationPlan({ apiKey: getValidatedApiKey(), - plannedEnvCleanup: [], read: readResult.read, }); @@ -77,7 +76,6 @@ test("env planner replaces OPENAI_API_KEY in place without disturbing unrelated const plan = buildEnvMutationPlan({ apiKey: getValidatedApiKey(), - plannedEnvCleanup: [], read: readResult.read, }); @@ -91,7 +89,7 @@ test("env planner replaces OPENAI_API_KEY in place without disturbing unrelated } }); -test("env planner clears canonical OPENAI_BASE_URL residue without needing extra env mutations beyond helper ownership", async () => { +test("env planner preserves canonical OPENAI_BASE_URL residue outside helper ownership", async () => { const harness = await createHermesIntegrationHarness({ fixture: "canonical-base-url", }); @@ -109,21 +107,23 @@ test("env planner clears canonical OPENAI_BASE_URL residue without needing extra const plan = buildEnvMutationPlan({ apiKey: getValidatedApiKey(), - plannedEnvCleanup: reviewPlanResult.result.plan.plannedEnvCleanup, read: reviewPlanResult.result.read, }); assert.deepEqual( plan.actions.map((action) => `${action.kind}:${action.key}`), - ["set:OPENAI_API_KEY", "delete:OPENAI_BASE_URL"], + ["set:OPENAI_API_KEY"], + ); + assert.equal( + plan.nextContents, + "OPENAI_BASE_URL=https://api.gonkagate.com/v1\nOPENAI_API_KEY=gp-phase-four-secret\n", ); - assert.equal(plan.nextContents, "OPENAI_API_KEY=gp-phase-four-secret\n"); } finally { await harness.cleanup(); } }); -test("env planner removes non-canonical file-backed OPENAI_BASE_URL while preserving unrelated keys", async () => { +test("env planner preserves non-canonical OPENAI_BASE_URL while replacing helper-owned key", async () => { const harness = await createHermesIntegrationHarness({ fixture: "shared-key-conflict", }); @@ -147,13 +147,12 @@ test("env planner removes non-canonical file-backed OPENAI_BASE_URL while preser const plan = buildEnvMutationPlan({ apiKey: getValidatedApiKey(), - plannedEnvCleanup: reviewPlanResult.result.plan.plannedEnvCleanup, read: reviewPlanResult.result.read, }); assert.equal( plan.nextContents, - "FOO=1\nOPENAI_API_KEY=gp-phase-four-secret\nBAR=2\n", + "FOO=1\nOPENAI_API_KEY=gp-phase-four-secret\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nBAR=2\n", ); } finally { await harness.cleanup(); diff --git a/test/fixtures/hermes-homes/review-plan-rich/.hermes/config.yaml b/test/fixtures/hermes-homes/review-plan-rich/.hermes/config.yaml index be3f774..21f43c1 100644 --- a/test/fixtures/hermes-homes/review-plan-rich/.hermes/config.yaml +++ b/test/fixtures/hermes-homes/review-plan-rich/.hermes/config.yaml @@ -7,9 +7,6 @@ model: providers: gonkagate: api: https://api.gonkagate.com/v1 - api_key: inline-provider-key - transport: responses - api_mode: codex_responses auxiliary: vision: provider: openrouter diff --git a/test/fixtures/launch-qualification/duplicate-recommended/alpha-model-a.md b/test/fixtures/launch-qualification/duplicate-recommended/alpha-model-a.md index d41bf8e..6a6bb7c 100644 --- a/test/fixtures/launch-qualification/duplicate-recommended/alpha-model-a.md +++ b/test/fixtures/launch-qualification/duplicate-recommended/alpha-model-a.md @@ -1,7 +1,7 @@ --- modelId: alpha/model-a qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/fixtures/launch-qualification/duplicate-recommended/beta-model-b.md b/test/fixtures/launch-qualification/duplicate-recommended/beta-model-b.md index 47bc7f4..793f803 100644 --- a/test/fixtures/launch-qualification/duplicate-recommended/beta-model-b.md +++ b/test/fixtures/launch-qualification/duplicate-recommended/beta-model-b.md @@ -1,7 +1,7 @@ --- modelId: beta/model-b qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/fixtures/launch-qualification/slug-mismatch/not-the-model.md b/test/fixtures/launch-qualification/slug-mismatch/not-the-model.md index 6e2eb2e..192ea82 100644 --- a/test/fixtures/launch-qualification/slug-mismatch/not-the-model.md +++ b/test/fixtures/launch-qualification/slug-mismatch/not-the-model.md @@ -1,7 +1,7 @@ --- modelId: alpha/model-a qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md b/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md index df98acf..f807c96 100644 --- a/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md +++ b/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md @@ -1,7 +1,7 @@ --- modelId: alpha/model-a qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/fixtures/launch-qualification/valid-multiple/qwen-qwen3-235b-a22b-instruct-2507-fp8.md b/test/fixtures/launch-qualification/valid-multiple/qwen-qwen3-235b-a22b-instruct-2507-fp8.md index aa0d90f..5270e34 100644 --- a/test/fixtures/launch-qualification/valid-multiple/qwen-qwen3-235b-a22b-instruct-2507-fp8.md +++ b/test/fixtures/launch-qualification/valid-multiple/qwen-qwen3-235b-a22b-instruct-2507-fp8.md @@ -1,7 +1,7 @@ --- modelId: qwen/qwen3-235b-a22b-instruct-2507-fp8 qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/fixtures/launch-qualification/valid-single/qwen-qwen3-235b-a22b-instruct-2507-fp8.md b/test/fixtures/launch-qualification/valid-single/qwen-qwen3-235b-a22b-instruct-2507-fp8.md index aa0d90f..5270e34 100644 --- a/test/fixtures/launch-qualification/valid-single/qwen-qwen3-235b-a22b-instruct-2507-fp8.md +++ b/test/fixtures/launch-qualification/valid-single/qwen-qwen3-235b-a22b-instruct-2507-fp8.md @@ -1,7 +1,7 @@ --- modelId: qwen/qwen3-235b-a22b-instruct-2507-fp8 qualifiedOn: 2026-04-15 -hermesReleaseTag: v2026.4.13 +hermesReleaseTag: v2026.5.16 hermesCommit: abcdef1234567890 osCoverage: - linux diff --git a/test/helpers/fake-hermes.mjs b/test/helpers/fake-hermes.mjs index 41d275e..5c4efe8 100644 --- a/test/helpers/fake-hermes.mjs +++ b/test/helpers/fake-hermes.mjs @@ -10,7 +10,7 @@ if (args.length === 1 && args[0] === "--version") { respond({ exitCode: Number(env.GONKAGATE_FAKE_HERMES_VERSION_EXIT_CODE ?? "0"), stderr: env.GONKAGATE_FAKE_HERMES_VERSION_STDERR ?? "", - stdout: env.GONKAGATE_FAKE_HERMES_VERSION_OUTPUT ?? "hermes-agent 0.9.0", + stdout: env.GONKAGATE_FAKE_HERMES_VERSION_OUTPUT ?? "hermes-agent 0.14.0", }); } diff --git a/test/helpers/harness.ts b/test/helpers/harness.ts index e6ea815..51e9b8b 100644 --- a/test/helpers/harness.ts +++ b/test/helpers/harness.ts @@ -293,7 +293,7 @@ import ${JSON.stringify(fakeHermesFixturePath)}; options.versionExitCode ?? 0, ), GONKAGATE_FAKE_HERMES_VERSION_OUTPUT: - options.versionOutput ?? "hermes-agent 0.9.0", + options.versionOutput ?? "hermes-agent 0.14.0", GONKAGATE_FAKE_HERMES_VERSION_STDERR: options.versionStderr ?? "", ...(options.configPathOutput === undefined ? {} diff --git a/test/hermes-normalized-read.test.ts b/test/hermes-normalized-read.test.ts index 45dc115..772b5d4 100644 --- a/test/hermes-normalized-read.test.ts +++ b/test/hermes-normalized-read.test.ts @@ -56,7 +56,7 @@ test("normalized read fails safely on malformed YAML", async () => { } }); -test("normalized read migrates legacy root provider/base_url into model.*", async () => { +test("normalized read ignores legacy root provider/base_url for the latest-only model view", async () => { const harness = await createHermesIntegrationHarness({ fixture: "legacy-root-config", }); @@ -76,9 +76,9 @@ test("normalized read migrates legacy root provider/base_url into model.*", asyn api: "", apiKey: "", apiMode: "", - baseUrl: "https://legacy-endpoint.example/v1", + baseUrl: "", defaultModel: "qwen3-32b", - provider: "custom", + provider: "", }); } finally { await harness.cleanup(); diff --git a/test/matching-providers.test.ts b/test/matching-providers.test.ts index 37ad603..5c6b91d 100644 --- a/test/matching-providers.test.ts +++ b/test/matching-providers.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; import test from "node:test"; import { classifyMatchingProviders } from "../src/hermes/conflicts/matching-providers.js"; import { createHermesIntegrationHarness } from "./helpers/harness.js"; @@ -30,7 +32,7 @@ test("matching provider classifier returns none when no named custom providers t } }); -test("matching provider classifier marks a single providers: entry with conflicting selectors as scrubbable", async () => { +test("matching provider classifier blocks a providers: entry with competing selectors", async () => { const harness = await createHermesIntegrationHarness({ fixture: "providers-dict-match", }); @@ -48,20 +50,16 @@ test("matching provider classifier marks a single providers: entry with conflict const conflict = classifyMatchingProviders(readResult.read); - assert.equal(conflict.status, "scrubbable"); + assert.equal(conflict.status, "blocking"); - if (conflict.status !== "scrubbable") { + if (conflict.status !== "blocking") { return; } const [match] = conflict.matchingEntries; + assert.equal(conflict.reason, "competing_provider_selectors"); assert.equal(match?.entry.sourceShape, "providers"); - assert.deepEqual([...(match?.scrubFields ?? [])].sort(), [ - "api_key", - "api_mode", - "transport", - ]); } finally { await harness.cleanup(); } @@ -97,6 +95,46 @@ test("matching provider classifier keeps a single canonical entry without compet } }); +test("matching provider classifier blocks a legacy custom_providers entry", async () => { + const harness = await createHermesIntegrationHarness({ + fixture: "clean-home", + }); + const configPath = resolve(harness.hermesHomeDir, "config.yaml"); + + try { + await writeFile( + configPath, + "custom_providers:\n gonkagate:\n base_url: https://api.gonkagate.com/v1\n", + "utf8", + ); + await harness.installFakeHermesOnPath(); + + const readResult = await loadNormalizedReadForFixture(harness); + + assert.equal(readResult.ok, true); + + if (!readResult.ok) { + return; + } + + const conflict = classifyMatchingProviders(readResult.read); + + assert.equal(conflict.status, "blocking"); + + if (conflict.status !== "blocking") { + return; + } + + assert.equal(conflict.reason, "legacy_custom_provider_entry"); + assert.equal( + conflict.matchingEntries[0]?.entry.sourceShape, + "custom_providers", + ); + } finally { + await harness.cleanup(); + } +}); + test("matching provider classifier blocks when multiple on-disk entries target the canonical GonkaGate URL", async () => { const harness = await createHermesIntegrationHarness({ fixture: "named-provider-conflict", diff --git a/test/model-picker.test.ts b/test/model-picker.test.ts index ec52620..e873b4d 100644 --- a/test/model-picker.test.ts +++ b/test/model-picker.test.ts @@ -13,7 +13,7 @@ function createQualifiedLiveModel( return { artifactPath: `/tmp/${modelId}.md`, hermesCommit: "abcdef1234567890", - hermesReleaseTag: "v2026.4.13", + hermesReleaseTag: "v2026.5.16", modelId, osCoverage: ["linux", "macos", "wsl2"], qualifiedOn: "2026-04-15", diff --git a/test/openai-base-url-conflicts.test.ts b/test/openai-base-url-conflicts.test.ts deleted file mode 100644 index 6264605..0000000 --- a/test/openai-base-url-conflicts.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { classifyOpenAiBaseUrlConflicts } from "../src/hermes/conflicts/openai-base-url.js"; -import { createHermesIntegrationHarness } from "./helpers/harness.js"; -import { loadNormalizedReadForFixture } from "./helpers/phase-two.js"; - -test("OPENAI_BASE_URL classifier plans cleanup for file-backed canonical residue", async () => { - const harness = await createHermesIntegrationHarness({ - fixture: "canonical-base-url", - }); - - try { - await harness.installFakeHermesOnPath(); - - const readResult = await loadNormalizedReadForFixture(harness); - - assert.equal(readResult.ok, true); - - if (!readResult.ok) { - return; - } - - assert.deepEqual(classifyOpenAiBaseUrlConflicts(readResult.read), [ - { - canonicalValue: "https://api.gonkagate.com/v1", - kind: "openai_base_url", - resolution: "clear_file_value_without_confirmation", - source: "file", - status: "planned_cleanup", - value: "https://api.gonkagate.com/v1", - }, - ]); - } finally { - await harness.cleanup(); - } -}); - -test("OPENAI_BASE_URL classifier requires confirmation for file-backed non-canonical values", async () => { - const harness = await createHermesIntegrationHarness({ - fixture: "shared-key-conflict", - }); - - try { - await harness.installFakeHermesOnPath(); - - const readResult = await loadNormalizedReadForFixture(harness); - - assert.equal(readResult.ok, true); - - if (!readResult.ok) { - return; - } - - const [conflict] = classifyOpenAiBaseUrlConflicts(readResult.read); - - assert.equal(conflict?.status, "confirmation_required"); - assert.equal(conflict?.source, "file"); - } finally { - await harness.cleanup(); - } -}); - -test("OPENAI_BASE_URL classifier surfaces inherited canonical values as advisories", async () => { - const harness = await createHermesIntegrationHarness({ - fixture: "clean-home", - }); - - try { - await harness.installFakeHermesOnPath(); - - const readResult = await loadNormalizedReadForFixture(harness, { - dependencyOverrides: { - runtime: { - env: { - OPENAI_BASE_URL: "https://api.gonkagate.com/v1", - }, - }, - }, - }); - - assert.equal(readResult.ok, true); - - if (!readResult.ok) { - return; - } - - assert.deepEqual(classifyOpenAiBaseUrlConflicts(readResult.read), [ - { - canonicalValue: "https://api.gonkagate.com/v1", - kind: "openai_base_url", - resolution: "warn_same_shell_runtime", - source: "inherited_process", - status: "advisory", - value: "https://api.gonkagate.com/v1", - }, - ]); - } finally { - await harness.cleanup(); - } -}); - -test("OPENAI_BASE_URL classifier blocks inherited non-canonical values", async () => { - const harness = await createHermesIntegrationHarness({ - fixture: "clean-home", - }); - - try { - await harness.installFakeHermesOnPath(); - - const readResult = await loadNormalizedReadForFixture(harness, { - dependencyOverrides: { - runtime: { - env: { - OPENAI_BASE_URL: "https://api.other-provider.example/v1", - }, - }, - }, - }); - - assert.equal(readResult.ok, true); - - if (!readResult.ok) { - return; - } - - assert.deepEqual(classifyOpenAiBaseUrlConflicts(readResult.read), [ - { - canonicalValue: "https://api.gonkagate.com/v1", - kind: "openai_base_url", - resolution: "unset_shell_and_rerun", - source: "inherited_process", - status: "blocking", - value: "https://api.other-provider.example/v1", - }, - ]); - } finally { - await harness.cleanup(); - } -}); diff --git a/test/openai-base-url-ignored.test.ts b/test/openai-base-url-ignored.test.ts new file mode 100644 index 0000000..584efdf --- /dev/null +++ b/test/openai-base-url-ignored.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createHermesIntegrationHarness } from "./helpers/harness.js"; +import { loadReviewPlanForFixture } from "./helpers/phase-two.js"; + +test("OPENAI_BASE_URL is ignored by latest-only review planning", async () => { + const harness = await createHermesIntegrationHarness({ + fixture: "clean-home", + }); + const envPath = resolve(harness.hermesHomeDir, ".env"); + + try { + await writeFile( + envPath, + "OPENAI_BASE_URL=https://api.other-provider.example/v1\n", + "utf8", + ); + await harness.installFakeHermesOnPath(); + + const reviewPlanResult = await loadReviewPlanForFixture(harness, { + dependencyOverrides: { + runtime: { + env: { + OPENAI_BASE_URL: "https://api.shell-provider.example/v1", + }, + }, + }, + }); + + assert.equal(reviewPlanResult.ok, true); + + if (!reviewPlanResult.ok) { + return; + } + + assert.deepEqual(reviewPlanResult.result.plan.blockingFindings, []); + assert.deepEqual(reviewPlanResult.result.plan.confirmationItems, []); + assert.deepEqual(reviewPlanResult.result.plan.plannedConfigScrubs, []); + } finally { + await harness.cleanup(); + } +}); diff --git a/test/phase-four-orchestration.test.ts b/test/phase-four-orchestration.test.ts index e5aaf68..7e2649c 100644 --- a/test/phase-four-orchestration.test.ts +++ b/test/phase-four-orchestration.test.ts @@ -91,7 +91,7 @@ test("phase-four orchestration can build and apply the mutation plan end to end" }); assert.equal( readFileSync(envPath, "utf8"), - "OPENAI_API_KEY=gp-phase-four-secret\n", + "OPENAI_API_KEY=gp-phase-four-secret\nOPENAI_BASE_URL=https://api.other-provider.example/v1\n", ); } finally { await server.close(); diff --git a/test/preconditions.test.ts b/test/preconditions.test.ts index 3e815eb..63cc23a 100644 --- a/test/preconditions.test.ts +++ b/test/preconditions.test.ts @@ -32,6 +32,7 @@ test("supported preflight returns a success report without writing files", async } assert.match(result.message, /Preflight checks passed/i); + assert.equal(result.preflight.hermesVersion, "0.14.0"); assert.equal(result.preflight.platform, "linux"); assert.equal( result.preflight.configPath, @@ -46,6 +47,116 @@ test("supported preflight returns a success report without writing files", async } }); +test("Hermes versions below the latest-only floor abort before path resolution", async () => { + const harness = await createHermesIntegrationHarness({ + fixture: "clean-home", + }); + + try { + await harness.installFakeHermesOnPath({ + versionOutput: "hermes-agent 0.9.0", + }); + + const result = await runPreflightChecks( + {}, + harness.createDependencies({ + runtime: { + osRelease: "6.8.0", + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + }, + }), + ); + + assert.equal(result.status, "failure"); + + if (result.status !== "failure") { + return; + } + + assert.equal(result.code, "unsupported_hermes_version"); + assert.match(result.message, /below the supported floor/i); + assert.deepEqual(await harness.readFakeHermesInvocations(), [ + ["--version"], + ]); + } finally { + await harness.cleanup(); + } +}); + +test("Hermes release-tag version output is accepted at the latest-only floor", async () => { + const harness = await createHermesIntegrationHarness({ + fixture: "clean-home", + }); + + try { + await harness.installFakeHermesOnPath({ + versionOutput: "v2026.5.16", + }); + + const result = await runPreflightChecks( + {}, + harness.createDependencies({ + runtime: { + osRelease: "6.8.0", + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + }, + }), + ); + + assert.equal(result.status, "success-preflight"); + + if (result.status !== "success-preflight") { + return; + } + + assert.equal(result.preflight.hermesVersion, "v2026.5.16"); + } finally { + await harness.cleanup(); + } +}); + +test("unparseable Hermes version output aborts before path resolution", async () => { + const harness = await createHermesIntegrationHarness({ + fixture: "clean-home", + }); + + try { + await harness.installFakeHermesOnPath({ + versionOutput: "Hermes Agent Foundation Release", + }); + + const result = await runPreflightChecks( + {}, + harness.createDependencies({ + runtime: { + osRelease: "6.8.0", + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + }, + }), + ); + + assert.equal(result.status, "failure"); + + if (result.status !== "failure") { + return; + } + + assert.equal(result.code, "unsupported_hermes_version"); + assert.match(result.message, /cannot validate/i); + assert.deepEqual(await harness.readFakeHermesInvocations(), [ + ["--version"], + ]); + } finally { + await harness.cleanup(); + } +}); + test("unsupported win32 aborts before Hermes is invoked", async () => { const harness = await createHermesIntegrationHarness({ fixture: "clean-home", diff --git a/test/qualified-models.test.ts b/test/qualified-models.test.ts index b876c4f..ef84d69 100644 --- a/test/qualified-models.test.ts +++ b/test/qualified-models.test.ts @@ -20,7 +20,7 @@ const checkedInQualificationRoot = resolve( "docs", "launch-qualification", "hermes-agent-setup", - "v2026.4.13", + "v2026.5.16", ); function createDependencies() { @@ -149,6 +149,32 @@ test("qualified live models are intersected with the live catalog and sorted by ); }); +test("unqualified live catalog entries are not exposed as selectable models", async () => { + const result = await loadQualifiedLiveModels( + { + modelIds: [ + "qwen/qwen3-235b-a22b-instruct-2507-fp8", + "unqualified/live-only-model", + ], + }, + createDependencies(), + { + artifactsRoot: resolveQualificationFixture("valid-single"), + }, + ); + + assert.equal(result.ok, true); + + if (!result.ok) { + return; + } + + assert.deepEqual( + result.result.qualifiedLiveModels.map((model) => model.modelId), + ["qwen/qwen3-235b-a22b-instruct-2507-fp8"], + ); +}); + test("checked-in Kimi artifact matches the live catalog model id casing", async () => { const result = await loadQualifiedLiveModels( { @@ -180,7 +206,6 @@ test("checked-in Kimi artifact matches the live catalog model id casing", async true, ); }); - test("qualified live model loading aborts on an empty live intersection", async () => { const result = await loadQualifiedLiveModels( { diff --git a/test/review-flow.test.ts b/test/review-flow.test.ts index 182f7e7..83a1189 100644 --- a/test/review-flow.test.ts +++ b/test/review-flow.test.ts @@ -32,7 +32,7 @@ function createStandardDependencyOverrides( }; } -test("review renderer produces one consolidated block with takeover, base-url cleanup, and provider scrub details", async () => { +test("review renderer produces one consolidated block with shared-key takeover details", async () => { const harness = await createHermesIntegrationHarness({ fixture: "review-plan-rich", }); @@ -72,14 +72,8 @@ test("review renderer produces one consolidated block with takeover, base-url cl writePlanResult.result.review.text, /Shared OPENAI_API_KEY takeover affects/, ); - assert.match( - writePlanResult.result.review.text, - /Clear file-backed OPENAI_BASE_URL=https:\/\/api\.other-provider\.example\/v1/, - ); - assert.match( - writePlanResult.result.review.text, - /Scrub matching provider "gonkagate" fields: api_key, api_mode, transport/, - ); + assert.doesNotMatch(writePlanResult.result.review.text, /Scrub matching/); + assert.doesNotMatch(writePlanResult.result.review.text, /OPENAI_BASE_URL/); assert.equal(writePlanResult.result.review.confirmationRequired, true); } finally { await server.close(); @@ -87,7 +81,7 @@ test("review renderer produces one consolidated block with takeover, base-url cl } }); -test("canonical OPENAI_BASE_URL cleanup appears in review output but skips confirmation", async () => { +test("canonical OPENAI_BASE_URL is not included in review cleanup", async () => { const harness = await createHermesIntegrationHarness({ fixture: "canonical-base-url", }); @@ -116,7 +110,7 @@ test("canonical OPENAI_BASE_URL cleanup appears in review output but skips confi } assert.equal(writePlanResult.result.review.confirmationRequired, false); - assert.match(writePlanResult.result.review.text, /Clear OPENAI_BASE_URL/); + assert.doesNotMatch(writePlanResult.result.review.text, /OPENAI_BASE_URL/); const executionResult = await executePhaseFourWritePlan( writePlanResult.result, @@ -127,6 +121,10 @@ test("canonical OPENAI_BASE_URL cleanup appears in review output but skips confi assert.equal(executionResult.status, "written"); assert.deepEqual(harness.readPromptInvocations().selectOptions, []); + assert.match( + readFileSync(resolve(harness.hermesHomeDir, ".env"), "utf8"), + /OPENAI_BASE_URL=https:\/\/api\.gonkagate\.com\/v1/, + ); } finally { await server.close(); await harness.cleanup(); diff --git a/test/review-plan-builder.test.ts b/test/review-plan-builder.test.ts index 41b85dd..b019a55 100644 --- a/test/review-plan-builder.test.ts +++ b/test/review-plan-builder.test.ts @@ -21,35 +21,14 @@ test("builder produces one deterministic pre-write review plan", async () => { assert.deepEqual( reviewPlanResult.result.plan.confirmationItems.map((item) => item.kind), - [ - "shared_openai_key_takeover", - "file_openai_base_url_cleanup", - "matching_provider_scrub", - ], + ["shared_openai_key_takeover"], ); assert.deepEqual( reviewPlanResult.result.plan.plannedConfigScrubs .map((scrub) => scrub.fieldPath) .sort(), - [ - "model.api", - "model.api_key", - "model.api_mode", - "providers.gonkagate.api_key", - "providers.gonkagate.api_mode", - "providers.gonkagate.transport", - ], + ["model.api", "model.api_key", "model.api_mode"], ); - assert.deepEqual(reviewPlanResult.result.plan.plannedEnvCleanup, [ - { - confirmationRequired: true, - existingValue: "https://api.other-provider.example/v1", - key: "OPENAI_BASE_URL", - reason: - "Clear conflicting OPENAI_BASE_URL before helper-managed onboarding can be deterministic.", - source: "file", - }, - ]); assert.deepEqual(reviewPlanResult.result.plan.blockingFindings, []); } finally { await harness.cleanup();