diff --git a/README.md b/README.md index 86c03e1..22768d8 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,17 @@ It configures Hermes to use: - `provider: custom` - `https://api.gonkagate.com/v1` -Your GonkaGate key is stored only in `~/.hermes/.env`. It is never written to -`config.yaml`. +Your raw GonkaGate key is stored only in `~/.hermes/.env`. It is never written +to `config.yaml`; the config only stores the `${GONKAGATE_API_KEY}` reference +Hermes needs for the custom endpoint. When setup succeeds, the helper writes only the GonkaGate-managed surface: - `model.provider` - `model.base_url` - `model.default` -- `OPENAI_API_KEY` +- `model.api_key = ${GONKAGATE_API_KEY}` +- `GONKAGATE_API_KEY` ## Important Limits diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3499a6a..3dee213 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -42,23 +42,24 @@ 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 and takeover confirmations. Legacy endpoint paths are not cleaned or + changes and blocking conflicts. 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. 10. Print the final summary, including target paths, applied cleanup, and the - reminder that `/v1/models` proved auth and catalog visibility only. + optional one-command Hermes smoke test. The summary still reminds users + that `/v1/models` proved auth and catalog visibility only. ## Product Boundaries 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 current `model.api_key`, `model.api`, and - incompatible `model.api_mode` cleanup when those compete with the managed - main endpoint +- it manages only `model.provider`, `model.base_url`, `model.default`, + `model.api_key = ${GONKAGATE_API_KEY}`, and `.env` `GONKAGATE_API_KEY`, + plus current `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 diff --git a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/minimaxai-minimax-m2-7.md b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/minimaxai-minimax-m2-7.md index 16440e4..b682280 100644 --- a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/minimaxai-minimax-m2-7.md +++ b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/minimaxai-minimax-m2-7.md @@ -22,12 +22,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: minimaxai/minimax-m2.7 + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn diff --git a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md index 95178e9..865a748 100644 --- a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md +++ b/docs/launch-qualification/hermes-agent-setup/v2026.5.16/moonshotai-kimi-k2-6.md @@ -22,12 +22,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: moonshotai/kimi-k2.6 + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn diff --git a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/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 index c855945..4260dc9 100644 --- a/docs/launch-qualification/hermes-agent-setup/v2026.5.16/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 @@ -22,12 +22,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: qwen/qwen3-235b-a22b-instruct-2507-fp8 + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn diff --git a/docs/security.md b/docs/security.md index ed19f82..fc4704c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,7 +11,9 @@ prompt. It does not accept a plain `--api-key` flag. The canonical secret contract is: - store the key only in the resolved Hermes `.env` file -- never write the key to `config.yaml` +- never write the raw key to `config.yaml` +- write only the non-secret `model.api_key = ${GONKAGATE_API_KEY}` reference + to `config.yaml` - never print the raw key to stdout or stderr - redact raw `gp-...` values and `Bearer` tokens in unexpected error paths @@ -22,10 +24,11 @@ The helper writes only the minimum GonkaGate-managed surface: - `model.provider` - `model.base_url` - `model.default` -- `OPENAI_API_KEY` +- `model.api_key = ${GONKAGATE_API_KEY}` +- `GONKAGATE_API_KEY` Conflict-only cleanup is limited to current model-owned surfaces: -`model.api_key`, `model.api`, and incompatible `model.api_mode`. +`model.api` and incompatible `model.api_mode`. Write safety rules: diff --git a/docs/specs/hermes-agent-setup-prd/implementation-plan.md b/docs/specs/hermes-agent-setup-prd/implementation-plan.md index 391b3dc..ffa99e2 100644 --- a/docs/specs/hermes-agent-setup-prd/implementation-plan.md +++ b/docs/specs/hermes-agent-setup-prd/implementation-plan.md @@ -228,8 +228,9 @@ Hermes behavior and must stay explicit about that boundary. ## Task 5: Implement shared-key and base-URL conflict classification **Description:** Encode the PRD finite matrix for shared `OPENAI_API_KEY` -takeover and the exact `OPENAI_BASE_URL` rules. The output of this task should -be typed conflict data that the review planner can render without guessing. +consumers and the exact `OPENAI_BASE_URL` rules. The output of this task should +be typed compatibility data that the review planner can render without +guessing. **Acceptance criteria:** @@ -248,7 +249,7 @@ be typed conflict data that the review planner can render without guessing. - [ ] Typecheck passes: `npm run typecheck` - [ ] Conflict-classification tests pass: `npm test` - [ ] Manual check: sample fixtures produce the expected matched-surface list - for takeover and `OPENAI_BASE_URL` scenarios + for shared-key and `OPENAI_BASE_URL` scenarios **Dependencies:** Task 4 @@ -444,13 +445,13 @@ limits. **Acceptance criteria:** - [ ] The planner writes or updates `model.provider`, `model.base_url`, - `model.default`, and canonical `model.api_mode` state without broad - config ownership. + `model.default`, `model.api_key = ${GONKAGATE_API_KEY}`, and canonical + `model.api_mode` state without broad config ownership. - [ ] Missing `config.yaml` produces only the exact minimal bootstrap contract from FR3 and does not materialize unrelated default sections. - [ ] Existing unrelated config is preserved semantically, while conflicting - `model.api_key`, `model.api`, incompatible `model.api_mode`, and allowed - matching-entry scrub fields are handled according to FR6. + `model.api`, incompatible `model.api_mode`, and allowed matching-entry + scrub fields are handled according to FR6. **Verification:** @@ -475,17 +476,16 @@ limits. **Description:** Plan the `.env` changes owned by the helper and render the single pre-write review block required by the PRD. This task covers -`OPENAI_API_KEY`, file-backed `OPENAI_BASE_URL` cleanup, and the one-prompt -confirmation rule. +`GONKAGATE_API_KEY` while preserving unrelated `OPENAI_API_KEY` and +file-backed `OPENAI_BASE_URL` residue outside helper ownership. **Acceptance criteria:** -- [ ] The `.env` planner writes `OPENAI_API_KEY=`, keeps unrelated env - keys intact, and applies the PRD cleanup rules for file-backed +- [ ] The `.env` planner writes `GONKAGATE_API_KEY=` and keeps unrelated + env keys intact, including existing `OPENAI_API_KEY` and `OPENAI_BASE_URL`. - [ ] The review renderer shows one consolidated block with planned writes, - cleanup actions, matched takeover surfaces, and any required confirmation. -- [ ] Declining the confirmation exits cleanly without touching any file. + cleanup actions, and blocking conflicts. **Verification:** @@ -603,7 +603,7 @@ behavior tests. **Acceptance criteria:** - [ ] The test matrix covers clean homes, missing config, malformed YAML, - shared-key takeover confirmation, inherited and file-backed + shared-key preservation, inherited and file-backed `OPENAI_BASE_URL`, matching named-provider conflicts, auth-pool aborts, backup/rollback paths, unsupported platforms, and explicit `--profile`. - [ ] Fixtures cover default profile, sticky profile, custom `HERMES_HOME`, @@ -848,7 +848,7 @@ This task is the final freeze gate before calling the PRD implemented. | Risk | Impact | Mitigation | | ------------------------------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Hermes normalized-read behavior drifts after the pinned upstream release | High | Keep the compatibility adapter explicit, back it with fixtures from the pinned release, and require requalification before claiming compatibility with newer Hermes releases | -| Shared `OPENAI_API_KEY` takeover is misclassified | High | Encode the finite matrix directly from FR4, prefer safe aborts, and keep dedicated fixtures for every matched surface | +| Shared `OPENAI_API_KEY` preservation is misclassified | High | Encode the finite matrix directly from FR4 and keep dedicated fixtures for every matched surface | | YAML merge or scrub logic damages unrelated user config | High | Limit the helper-managed surface, preserve unrelated semantics in tests, and keep backup plus rollback behavior mandatory | | Live catalog and allowlist drift to zero launchable models | Medium | Load allowlist from checked-in qualification artifacts and abort cleanly on empty intersection before any write | | Public docs, package text, and skills drift away from shipped behavior | Medium | Delay the doc flip until runtime completion, keep contract tests strict, and update mirrored skills in the same phase | diff --git a/docs/specs/hermes-agent-setup-prd/spec.md b/docs/specs/hermes-agent-setup-prd/spec.md index a3eea7e..1a436a9 100644 --- a/docs/specs/hermes-agent-setup-prd/spec.md +++ b/docs/specs/hermes-agent-setup-prd/spec.md @@ -79,7 +79,7 @@ Source-backed facts: target-home semantics cannot be reduced only to raw `HERMES_HOME` fallback. - In the verified release runtime, `provider: custom` still depends on `model.api_key` / `model.api` and `model.api_mode`, so the helper cannot - treat these fields as harmless residue after takeover. + treat these fields as harmless residue after onboarding. - `OPENAI_API_KEY` in Hermes is used not only for the main custom endpoint, but also as a fallback for some OpenRouter resolution paths, direct-endpoint auxiliary/delegation/fallback flows without a separate explicit key, and @@ -240,14 +240,16 @@ Desired behavior: classification when raw file inspection or public seams alone are insufficient for helper safety decisions - a helper-owned success snapshot after one successful run: - - helper writes `OPENAI_API_KEY=` to the resolved Hermes `.env` + - helper writes `GONKAGATE_API_KEY=` to the resolved Hermes `.env` - helper writes `model.provider = "custom"` - helper writes `model.base_url = "https://api.gonkagate.com/v1"` - helper writes `model.default = ` + - helper writes `model.api_key = ${GONKAGATE_API_KEY}` as a non-secret env + reference - helper itself does not persist the GonkaGate secret in `config.yaml` - helper clears helper-detected conflicting auth/protocol fields under `model` if they would override or make the GonkaGate path ambiguous: - `model.api_key`, `model.api`, incompatible `model.api_mode` + `model.api`, incompatible `model.api_mode` - helper also resolves or aborts on helper-detected matching entries under `custom_providers` / `providers:` that point to the same canonical GonkaGate URL and would remain an active competing credential/protocol @@ -322,10 +324,10 @@ Happy path: 8. If the live catalog is unavailable or the intersection is empty, the utility exits before writing with a clear message. 9. The utility builds a deterministic pre-write plan: target writes, - matching-entry cleanup, `OPENAI_BASE_URL` resolution, and shared-secret - takeover impact. -10. If the plan contains confirm-required destructive changes, the utility - shows one consolidated review block and asks for confirmation once. + matching-entry checks, `OPENAI_BASE_URL` preservation, and dedicated + GonkaGate credential writes. +10. If the plan contains blocking conflicts, the utility exits with clear + manual-resolution guidance before writing. 11. The utility backs up the files it will actually modify. 12. The utility writes the helper-managed config surface and `.env` with per-file atomic writes, rollback-safe ordering, and without falsely @@ -420,6 +422,7 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: + api_key: ${GONKAGATE_API_KEY} ``` The v1 helper creates no other top-level sections on first write. @@ -433,12 +436,13 @@ The API key must be accepted only through a hidden interactive prompt. The utility must: - validate that the key looks like `gp-...` -- write it to `~/.hermes/.env` as `OPENAI_API_KEY=` +- write it to `~/.hermes/.env` as `GONKAGATE_API_KEY=` - not write it to stdout -- not write it to `config.yaml` -- before writing, evaluate whether replacing the shared `OPENAI_API_KEY` would - cause unintended takeover of other Hermes flows that fall back to - `OPENAI_API_KEY` +- not write the raw key to `config.yaml` +- write only `model.api_key = ${GONKAGATE_API_KEY}` to `config.yaml` so latest + Hermes sends the dedicated GonkaGate credential to the custom endpoint +- preserve any unrelated existing `OPENAI_API_KEY` instead of treating it as + the GonkaGate main-path secret If the helper detects existing non-GonkaGate state that may use the shared `OPENAI_API_KEY` outside the main `model.*` path, the helper must: @@ -479,20 +483,10 @@ If the helper detects existing non-GonkaGate state that may use the shared `stt.provider == "openai"` with empty `VOICE_TOOLS_OPENAI_KEY` - explicitly list only the affected surfaces that matched this finite matrix; for cron jobs, the helper shows at least the job name or ID when available -- not continue silently -- continue only after explicit user takeover confirmation, or else exit - without writing -- if the resolved `cron/jobs.json` exists but the helper cannot reliably read - it while evaluating the shared-key blast radius, the helper must abort -- if the helper detects state outside this finite matrix and cannot prove that - overwriting `OPENAI_API_KEY` will not change runtime behavior, it must exit - without writing rather than do an optimistic overwrite - -Default v1 policy: safe abort first. Explicit takeover confirmation is allowed -only when the helper can clearly enumerate the matched surfaces from the finite -matrix and the user confirms takeover in the interactive flow. - -The v1 success path must not leave unresolved shared-secret ambiguity. +- preserve unrelated shared `OPENAI_API_KEY` state instead of continuing with + an implicit takeover +- if the helper detects state outside this finite matrix, it must preserve + unrelated `OPENAI_API_KEY` state rather than do an optimistic overwrite Dedicated-credential proof rules for v1: @@ -528,11 +522,10 @@ Dedicated-credential proof rules for v1: Pre-write review UX for v1: - the helper computes one consolidated review plan before any write -- the helper shows at most one confirmation prompt for the whole run -- confirmation is required when at least one of the following is true: - - shared `OPENAI_API_KEY` takeover affects one or more matched surfaces from - the finite matrix - - a non-empty non-canonical `OPENAI_BASE_URL` must be cleared +- shared `OPENAI_API_KEY` state is not a confirmation item for the GonkaGate + main path because the helper writes the dedicated `GONKAGATE_API_KEY` + boundary and preserves unrelated OpenAI state +- blocking is required when at least one of the following is true: - matching `custom_providers` / `providers:` entries contain auth/protocol fields that must be scrubbed - if the only planned legacy cleanup is `OPENAI_BASE_URL` already equal to @@ -583,8 +576,9 @@ Minimum Hermes smoke suite for inclusion of a model in the v1 allowlist: - successful Hermes tool-use turn with a harmless local tool on a clean `HERMES_HOME` - no launch-blocking regressions in the path that the helper actually - configures: `provider: custom` + `model.base_url` in `config.yaml` + - `OPENAI_API_KEY` in `.env` + configures: `provider: custom` + `model.base_url` + + `model.api_key = ${GONKAGATE_API_KEY}` in `config.yaml` + + `GONKAGATE_API_KEY` in `.env` Launch qualification evidence required for every allowlisted model: @@ -621,13 +615,13 @@ Managed surface for v1: - `model.provider` - `model.base_url` - `model.default` -- `OPENAI_API_KEY` +- `model.api_key = ${GONKAGATE_API_KEY}` +- `GONKAGATE_API_KEY` - canonical main-path protocol selector: - compatible state is an empty / absent `model.api_mode`, or explicit `model.api_mode == "chat_completions"` - conflict-only cleanup surface if existing values override or make the canonical GonkaGate path ambiguous: - - `model.api_key` - `model.api` - any non-empty `model.api_mode` other than `"chat_completions"` - matching `custom_providers[].api_key` @@ -662,7 +656,7 @@ Helper must: - use Hermes CLI primarily for path discovery and other public seams where they provide sufficient signal - not write the GonkaGate secret through argv-bearing Hermes CLI mutation - commands such as `hermes config set OPENAI_API_KEY ...`; a helper-owned + commands such as `hermes config set GONKAGATE_API_KEY ...`; a helper-owned atomic `.env` write is the only supported secret-persistence path for v1 - make runtime conflict decisions against a Hermes-compatible normalized read view equivalent to the release-pinned `load_config()`, including `${VAR}` @@ -677,10 +671,10 @@ Helper must: flow is broader than our product and writes more than we need - after helper completion, the canonical main-path protocol selector must be either absent / empty `model.api_mode`, or explicit `"chat_completions"` -- not leave stale `model.api_key` / `model.api` / incompatible - `model.api_mode` (`"codex_responses"`, `"anthropic_messages"`, or any other - non-empty value besides `"chat_completions"`) if they would override the - helper-owned GonkaGate path +- not leave stale `model.api` / incompatible `model.api_mode` + (`"codex_responses"`, `"anthropic_messages"`, or any other non-empty value + besides `"chat_completions"`) if they would override the helper-owned + GonkaGate path - not treat top-level `model.*` as the only active custom credential source; helper must inspect matching `custom_providers` / `providers:` compatibility state before claiming success @@ -854,10 +848,10 @@ Exact write ordering for v1: failure as non-success and print explicit recovery instructions with backup paths. -The chosen ordering is a blast-radius decision: writing `config.yaml` first is -preferred over writing `OPENAI_API_KEY` first, because a premature shared-key -takeover can affect more Hermes surfaces than a temporarily updated main -`model.*` path. +The chosen ordering is a blast-radius decision: write the non-secret +`config.yaml` reference first, then persist the dedicated `GONKAGATE_API_KEY` +secret in `.env`. If the later secret write fails, rollback removes the +temporary main-path config reference. ### FR10. Verification Semantics @@ -892,16 +886,13 @@ The utility must stop before writing under the following conditions: - the hidden prompt is unavailable because there is no TTY - the target install is in managed mode or upstream-blocked write mode - `config.yaml` exists but does not parse as YAML -- the resolved `cron/jobs.json` exists, but the helper cannot reliably read it - while evaluating the shared `OPENAI_API_KEY` blast radius - the API key fails basic validation - live `GET /v1/models` did not return a usable qualified result - live `GET /v1/models` returned a terminal auth/access failure - live `GET /v1/models` exhausted the bounded retry budget on a transient catalog or server failure -- an unresolved conflict is detected around the shared `OPENAI_API_KEY` -- an unresolved conflict is detected around `model.api_key` / `model.api` / - incompatible `model.api_mode` +- an unresolved conflict is detected around `model.api` / incompatible + `model.api_mode` - an unresolved matching-entry conflict is detected in `custom_providers` / `providers:` for the canonical GonkaGate URL - a matching custom credential-pool conflict is detected in the resolved @@ -948,13 +939,15 @@ What is different: existing setup. 4. Existing `model.api_key` / `model.api` / `model.api_mode` can survive onboarding and create a false-success state if the helper does not take - explicit ownership of those conflicts. + explicit ownership of those fields. The current helper owns + `model.api_key` by setting `${GONKAGATE_API_KEY}` and cleans conflicting + `model.api` / `model.api_mode`. 5. Matching entries under `custom_providers` / `providers:` can survive onboarding and remain a second active credential source for the same URL if the helper looks only at `model.*`. 6. Matching custom credential pools in `auth.json` can survive onboarding and - quietly beat the helper-owned `OPENAI_API_KEY` if the launch contract does - not pin an explicit abort boundary. + quietly beat the helper-owned main path if the launch contract does not pin + an explicit abort boundary. 7. A shell-exported `OPENAI_BASE_URL` can survive cleanup of the resolved `.env` and leave a same-shell false-success state if the helper does not distinguish file-backed and inherited env conflicts. @@ -982,7 +975,7 @@ What is different: 17. Hermes docs still describe `fallback_model.api_key_env`, but the verified fallback activation path does not use it directly; if the helper starts proving safety through doc-only fallback semantics, it can miss a real - shared-key takeover surface. + shared-key consumer surface. ## Assumptions @@ -999,13 +992,13 @@ What is different: - [assumption] Storing the secret in `.env` is better than in `config.yaml`, even if the upstream custom flow still allows `model.api_key`. - Risk: upstream Hermes-owned flows may still rematerialize the secret in - `model.api_key` or in a matching named custom-provider entry after the - helper run. - Validation: smoke-test runtime using only `OPENAI_API_KEY` in `.env`, - `model.base_url` in config, and without stale `model.api_key` / `model.api` - / incompatible `model.api_mode`; separately document that this is a - helper-owned invariant at write time, not an indefinite global invariant. + Risk: upstream Hermes-owned flows may still rematerialize a literal secret + in a matching named custom-provider entry after the helper run. + Validation: smoke-test runtime using `GONKAGATE_API_KEY` in `.env`, + `model.api_key = ${GONKAGATE_API_KEY}` and `model.base_url` in config, and + without stale `model.api` / incompatible `model.api_mode`; separately + document that this is a helper-owned invariant at write time, not an + indefinite global invariant. - [assumption] For v1, relying on Hermes path seams and explicit `--profile` is sufficient, without inventing a separate helper-specific home-resolution @@ -1040,13 +1033,11 @@ What is different: 4. The v1 launch boundary is finalized as Linux, macOS, and WSL2 only. Native Windows and Android / Termux are explicitly unsupported at launch. -5. Existing shared `OPENAI_API_KEY` state is handled with a safe-abort-first - policy. The helper may proceed only after explicit takeover confirmation - when it can clearly enumerate the matched affected surface from the finite - detection matrix in FR4. That matrix includes `smart_model_routing`, - auxiliary/fallback/cron OpenRouter override surfaces, and treats ambiguous - `cheap_model.provider == "custom"` without an explicit `base_url` as - blocking in v1. +5. Existing shared `OPENAI_API_KEY` state is preserved instead of being used as + the GonkaGate main-path credential. The helper writes `GONKAGATE_API_KEY` + and `model.api_key = ${GONKAGATE_API_KEY}`, so unrelated OpenAI consumers + are no longer takeover-confirmation surfaces for the primary onboarding + path. 6. Matching entries under `custom_providers` / `providers:` that resolve to the canonical GonkaGate URL are not treated as harmless residue. If they @@ -1120,11 +1111,12 @@ Primary: - the user configures GonkaGate in Hermes in one short flow without manually editing files -- after completion, the user's `model.provider`, `model.base_url`, and - `model.default` are set correctly +- after completion, the user's `model.provider`, `model.base_url`, + `model.default`, and `model.api_key = ${GONKAGATE_API_KEY}` are set + correctly - the helper writes the GonkaGate secret only to the resolved Hermes `.env` -- the helper does not leave stale conflicting `model.api_key` / `model.api` / - incompatible `model.api_mode` +- the helper does not leave stale conflicting `model.api` / incompatible + `model.api_mode` - the helper does not leave an unresolved matching `custom_providers` / `providers:` auth/protocol source for the same canonical GonkaGate URL - the helper does not claim success while a matching custom credential pool in @@ -1158,12 +1150,14 @@ For v1, it is sufficient to have: - retry/error-classification behavior for `/v1/models` (`401`, terminal auth/access failures, transient `5xx/503`, malformed response) - launch qualification evidence for every model in the qualified allowlist -- shared `OPENAI_API_KEY` takeover UX -- the finite detection matrix for shared `OPENAI_API_KEY` blast radius +- preservation of unrelated shared `OPENAI_API_KEY` state +- the finite detection matrix for historical shared `OPENAI_API_KEY` blast + radius - `smart_model_routing` shared-key detection and blocking behavior - auxiliary/fallback/cron OpenRouter override detection behavior - `cron/jobs.json` direct-endpoint detection behavior -- stale `model.api_key` / `model.api` / `model.api_mode` resolution UX +- stale `model.api_key` / `model.api` / `model.api_mode` resolution UX, + including replacing `model.api_key` with `${GONKAGATE_API_KEY}` - matching `custom_providers` / `providers:` conflict-resolution UX - matching `auth.json` custom credential-pool conflict UX - conflicting file-backed vs inherited-process-env `OPENAI_BASE_URL` @@ -1187,7 +1181,7 @@ Seams to pressure-test before implementation: 1. The ownership boundary between public Hermes read seams, `.env` writes, and helper-owned config writes. 2. Helper behavior when a non-standard `model` section already exists. -3. Helper behavior for shared `OPENAI_API_KEY` conflicts, +3. Helper behavior for preserving shared `OPENAI_API_KEY`, `smart_model_routing`, and existing direct endpoint overrides. 4. Helper behavior for live `/models` success, zero qualified intersection, terminal auth/access failures, and transient network failures. diff --git a/docs/specs/hermes-latest-contract-adaptation/spec.md b/docs/specs/hermes-latest-contract-adaptation/spec.md index f222f35..75dda9a 100644 --- a/docs/specs/hermes-latest-contract-adaptation/spec.md +++ b/docs/specs/hermes-latest-contract-adaptation/spec.md @@ -1,8 +1,10 @@ # Hermes Latest Contract Adaptation Status: draft -Last updated: 2026-05-24 -Target upstream release: Hermes Agent `v2026.5.16` / Hermes `v0.14.0` +Last updated: 2026-06-02 +Verified upstream compatibility: Hermes Agent `v2026.5.29.2` / Hermes +`v0.15.2` +Minimum supported release remains Hermes Agent `v2026.5.16` / Hermes `v0.14.0` ## Purpose @@ -20,17 +22,20 @@ migration tool. - 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`. + `model.base_url`, `model.default`, and + `model.api_key = ${GONKAGATE_API_KEY}`. +- The supported secret contract is `.env` `GONKAGATE_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. + endpoint remain safety checks: `model.api`, incompatible `model.api_mode`, + matching auth pools, cron jobs with direct `base_url`, and shared + `OPENAI_API_KEY` reuse surfaces. Shared `OPENAI_API_KEY` state is no longer + a helper takeover blocker because the GonkaGate main path uses the dedicated + `GONKAGATE_API_KEY`. - Launch qualification must be refreshed against `v2026.5.16` before release docs or model artifacts claim that baseline. @@ -62,6 +67,7 @@ migration tool. - 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. + `model.default`, `model.api_key = ${GONKAGATE_API_KEY}`, and `.env` + `GONKAGATE_API_KEY` for the primary onboarding path. - `npm run ci` passes after code, tests, docs, and qualification metadata are reconciled. diff --git a/scripts/launch-qualification/build-artifact.mjs b/scripts/launch-qualification/build-artifact.mjs index 408c9be..400919d 100644 --- a/scripts/launch-qualification/build-artifact.mjs +++ b/scripts/launch-qualification/build-artifact.mjs @@ -42,7 +42,7 @@ function redactSensitiveText(text) { return text .replace(/Bearer\s+[^\s]+/giu, "Bearer [REDACTED]") .replace(/\bgp-[A-Za-z0-9._-]+\b/gu, "[REDACTED]") - .replace(/(OPENAI_API_KEY=).+/gu, "$1[REDACTED]"); + .replace(/((?:GONKAGATE|OPENAI)_API_KEY=).+/gu, "$1[REDACTED]"); } function readRequiredString(value, label) { diff --git a/scripts/launch-qualification/validate-artifacts.mjs b/scripts/launch-qualification/validate-artifacts.mjs index 498ae8d..d86888b 100644 --- a/scripts/launch-qualification/validate-artifacts.mjs +++ b/scripts/launch-qualification/validate-artifacts.mjs @@ -97,7 +97,7 @@ function readStringArray(value, label, artifactPath) { return normalized; } -function validateArtifactDocument(artifactPath, sourceText, pinnedReleaseTag) { +function validateArtifactDocument(artifactPath, sourceText, constants) { const frontMatterMatch = sourceText.match( /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u, ); @@ -151,9 +151,9 @@ function validateArtifactDocument(artifactPath, sourceText, pinnedReleaseTag) { throw new Error(`${artifactPath}: recommended must be a boolean.`); } - if (hermesReleaseTag !== pinnedReleaseTag) { + if (hermesReleaseTag !== constants.PINNED_HERMES_RELEASE_TAG) { throw new Error( - `${artifactPath}: expected hermesReleaseTag ${pinnedReleaseTag}, received ${hermesReleaseTag}.`, + `${artifactPath}: expected hermesReleaseTag ${constants.PINNED_HERMES_RELEASE_TAG}, received ${hermesReleaseTag}.`, ); } @@ -171,6 +171,26 @@ function validateArtifactDocument(artifactPath, sourceText, pinnedReleaseTag) { ); } + if ( + !bodyText.includes(`api_key: ${constants.GONKAGATE_API_KEY_CONFIG_REF}`) + ) { + throw new Error( + `${artifactPath}: sanitized config must include model.api_key = ${constants.GONKAGATE_API_KEY_CONFIG_REF}.`, + ); + } + + if (!bodyText.includes(`${constants.GONKAGATE_API_KEY_ENV_VAR}=[REDACTED]`)) { + throw new Error( + `${artifactPath}: sanitized env must include ${constants.GONKAGATE_API_KEY_ENV_VAR}=[REDACTED].`, + ); + } + + if (bodyText.includes("OPENAI_API_KEY=[REDACTED]")) { + throw new Error( + `${artifactPath}: sanitized env must not use OPENAI_API_KEY for the GonkaGate helper key.`, + ); + } + return { hermesCommit, hermesReleaseTag, @@ -231,7 +251,7 @@ async function main() { const artifact = validateArtifactDocument( artifactPath, sourceText, - constants.PINNED_HERMES_RELEASE_TAG, + constants, ); const duplicateKey = `${artifact.hermesReleaseTag}:${artifact.modelId}`; diff --git a/src/constants/contract.ts b/src/constants/contract.ts index 910fa52..04f43cb 100644 --- a/src/constants/contract.ts +++ b/src/constants/contract.ts @@ -13,6 +13,8 @@ export const CONTRACT_METADATA = { minimumHermesVersion: "0.14.0", pinnedHermesReleaseTag: "v2026.5.16", pinnedHermesVersion: "v0.14.0", + gonkagateApiKeyEnvVar: "GONKAGATE_API_KEY", + gonkagateApiKeyConfigRef: "${GONKAGATE_API_KEY}", launchQualificationArtifactRoot: "docs/launch-qualification/hermes-agent-setup", nodeFloor: ">=22.14.0", @@ -24,13 +26,10 @@ export const CONTRACT_METADATA = { "model.provider", "model.base_url", "model.default", - ] as const, - helperManagedSecretEnvKeys: ["OPENAI_API_KEY"] as const, - helperCleanupConfigKeys: [ "model.api_key", - "model.api", - "model.api_mode", ] as const, + helperManagedSecretEnvKeys: ["GONKAGATE_API_KEY"] as const, + helperCleanupConfigKeys: ["model.api", "model.api_mode"] as const, } as const; export const PACKAGE_NAME = CONTRACT_METADATA.packageName; @@ -38,6 +37,10 @@ export const PRIMARY_BIN_NAME = CONTRACT_METADATA.binName; export const SECONDARY_BIN_NAME = CONTRACT_METADATA.legacyBinName; export const PACKAGE_DESCRIPTION = CONTRACT_METADATA.packageDescription; export const CANONICAL_BASE_URL = CONTRACT_METADATA.canonicalBaseUrl; +export const GONKAGATE_API_KEY_ENV_VAR = + CONTRACT_METADATA.gonkagateApiKeyEnvVar; +export const GONKAGATE_API_KEY_CONFIG_REF = + CONTRACT_METADATA.gonkagateApiKeyConfigRef; export const PRD_PATH = CONTRACT_METADATA.prdPath; export const PINNED_HERMES_RELEASE_TAG = CONTRACT_METADATA.pinnedHermesReleaseTag; diff --git a/src/planning/blocking-failures.ts b/src/planning/blocking-failures.ts index 9fdd3a5..a7d4492 100644 --- a/src/planning/blocking-failures.ts +++ b/src/planning/blocking-failures.ts @@ -68,8 +68,8 @@ function createSharedKeyFailure( surfaceId: conflict.surfaceId, }, guidance: - "Resolve the ambiguous shared OPENAI_API_KEY surface in Hermes config before rerunning the helper.", + "Preserve the unrelated OPENAI_API_KEY state or isolate that Hermes surface with a dedicated credential before rerunning the helper.", message: - "The helper found a blocking shared OPENAI_API_KEY surface that it cannot take over safely in v1.", + "The helper found a blocking shared OPENAI_API_KEY surface in the compatibility conflict matrix.", }); } diff --git a/src/planning/review-plan-builder.ts b/src/planning/review-plan-builder.ts index 14c7b1b..abb7a08 100644 --- a/src/planning/review-plan-builder.ts +++ b/src/planning/review-plan-builder.ts @@ -1,5 +1,6 @@ import type { MatchingProviderConflict, + PreWriteReviewConfirmationItem, PlannedConfigScrub, PreWriteReviewPlan, } from "../domain/conflicts.js"; @@ -57,28 +58,12 @@ export function buildPreWriteReviewPlan( const plannedConfigScrubs = [...collectModelScrubs(read)]; const blockingFindings = [ - ...sharedOpenAiKeyConflicts.filter( - (conflict) => conflict.status === "blocking", - ), ...(matchingProviderConflict.status === "blocking" ? [matchingProviderConflict] : []), ...(authPoolConflict.status === "blocking" ? [authPoolConflict] : []), ]; - const confirmationItems = [ - ...(sharedOpenAiKeyConflicts.some( - (conflict) => conflict.status === "confirmation_required", - ) - ? [ - { - conflicts: sharedOpenAiKeyConflicts.filter( - (conflict) => conflict.status === "confirmation_required", - ), - kind: "shared_openai_key_takeover" as const, - }, - ] - : []), - ]; + const confirmationItems: PreWriteReviewConfirmationItem[] = []; return { authPoolConflict, @@ -99,16 +84,6 @@ function collectModelScrubs( const scrubs: PlannedConfigScrub[] = []; const model = read.config.model; - if (model.apiKey.length > 0) { - scrubs.push({ - fieldPath: "model.api_key", - pathSegments: ["model", "api_key"], - reason: - "Clear model.api_key so the GonkaGate secret lives only in ~/.hermes/.env.", - target: "model", - }); - } - if (model.api.length > 0) { scrubs.push({ fieldPath: "model.api", diff --git a/src/ui/success.ts b/src/ui/success.ts index 91342ca..65b5cbe 100644 --- a/src/ui/success.ts +++ b/src/ui/success.ts @@ -1,4 +1,8 @@ -import { CANONICAL_BASE_URL } from "../constants/contract.js"; +import { + CANONICAL_BASE_URL, + GONKAGATE_API_KEY_CONFIG_REF, + GONKAGATE_API_KEY_ENV_VAR, +} from "../constants/contract.js"; import type { OnboardCancelledResult, OnboardSuccessResult, @@ -15,10 +19,12 @@ export function renderOnboardSuccess(result: OnboardSuccessResult): string { "- model.provider = custom", `- model.base_url = ${CANONICAL_BASE_URL}`, `- model.default = ${result.selectedModelId}`, + `- model.api_key = ${GONKAGATE_API_KEY_CONFIG_REF}`, "Applied file changes:", ...renderAppliedChanges(result), "Next steps:", "- Run `hermes` in this resolved context to start using the configured GonkaGate model.", + '- Optional smoke test: `hermes chat -Q --max-turns 1 -q "Do not use tools. Reply exactly: GonkaGate smoke test OK"` (sends one real model request).', "- 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.", "", ]; @@ -45,7 +51,7 @@ function renderAppliedChanges(result: OnboardSuccessResult): readonly string[] { ...result.writeResult.env.actions.map((action) => action.kind === "delete" ? `- Cleared ${action.key}` - : "- Saved OPENAI_API_KEY in the resolved Hermes .env file.", + : `- Saved ${GONKAGATE_API_KEY_ENV_VAR} in the resolved Hermes .env file.`, ), ]; diff --git a/src/writes/config-plan.ts b/src/writes/config-plan.ts index 8065470..ee378b8 100644 --- a/src/writes/config-plan.ts +++ b/src/writes/config-plan.ts @@ -1,5 +1,8 @@ import YAML from "yaml"; -import { CANONICAL_BASE_URL } from "../constants/contract.js"; +import { + CANONICAL_BASE_URL, + GONKAGATE_API_KEY_CONFIG_REF, +} from "../constants/contract.js"; import type { PlannedConfigScrub } from "../domain/conflicts.js"; import { createOnboardFailure, @@ -57,6 +60,12 @@ export function buildConfigMutationPlan(input: BuildConfigMutationPlanInput): "model.default", input.selectedModelId, ), + ...planManagedModelField( + modelRoot, + ["model", "api_key"], + "model.api_key", + GONKAGATE_API_KEY_CONFIG_REF, + ), ); for (const scrub of input.plannedConfigScrubs) { @@ -160,7 +169,10 @@ function loadEditableConfigRoot(read: NormalizedHermesRead): function planManagedModelField( modelRoot: Record, - pathSegments: readonly ["model", "provider" | "base_url" | "default"], + pathSegments: readonly [ + "model", + "provider" | "base_url" | "default" | "api_key", + ], fieldPath: string, nextValue: string, ): readonly ConfigMutationAction[] { diff --git a/src/writes/env-plan.ts b/src/writes/env-plan.ts index 5471ac9..7b1dc73 100644 --- a/src/writes/env-plan.ts +++ b/src/writes/env-plan.ts @@ -1,3 +1,4 @@ +import { GONKAGATE_API_KEY_ENV_VAR } from "../constants/contract.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"; @@ -20,18 +21,18 @@ export function buildEnvMutationPlan( const actions: EnvMutationAction[] = []; const nextApiKey = input.apiKey; - if (currentValues.OPENAI_API_KEY !== nextApiKey) { + if (currentValues[GONKAGATE_API_KEY_ENV_VAR] !== nextApiKey) { actions.push({ - key: "OPENAI_API_KEY", + key: GONKAGATE_API_KEY_ENV_VAR, kind: "set", nextValueDisplay: "[hidden GonkaGate API key]", sensitive: true, }); - currentValues.OPENAI_API_KEY = nextApiKey; + currentValues[GONKAGATE_API_KEY_ENV_VAR] = nextApiKey; } - if (!orderedKeys.includes("OPENAI_API_KEY")) { - orderedKeys.push("OPENAI_API_KEY"); + if (!orderedKeys.includes(GONKAGATE_API_KEY_ENV_VAR)) { + orderedKeys.push(GONKAGATE_API_KEY_ENV_VAR); } const nextOrderedKeys = orderedKeys.filter((key) => key in currentValues); diff --git a/test/cli.test.ts b/test/cli.test.ts index 3fddc75..1116381 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -137,10 +137,13 @@ test("supported setup completes the public onboarding flow end to end", async () stdout.contents, /model\.default = qwen\/qwen3-235b-a22b-instruct-2507-fp8/, ); - assert.match(stdout.contents, /Saved OPENAI_API_KEY/); + assert.match(stdout.contents, /model\.api_key = \$\{GONKAGATE_API_KEY\}/); + assert.match(stdout.contents, /Saved GONKAGATE_API_KEY/); + assert.match(stdout.contents, /Optional smoke test:/); assert.equal(stderr.contents, ""); assert.deepEqual(YAML.parse(readFileSync(configPath, "utf8")), { model: { + api_key: "${GONKAGATE_API_KEY}", base_url: "https://api.gonkagate.com/v1", default: "qwen/qwen3-235b-a22b-instruct-2507-fp8", provider: "custom", @@ -148,7 +151,7 @@ test("supported setup completes the public onboarding flow end to end", async () }); assert.equal( readFileSync(envPath, "utf8"), - "OPENAI_API_KEY=gp-cli-secret\n", + "GONKAGATE_API_KEY=gp-cli-secret\n", ); assert.equal(modelsServer.getRequestCount(), 1); assert.deepEqual(await harness.readFakeHermesInvocations(), [ diff --git a/test/config-plan.test.ts b/test/config-plan.test.ts index 823eb15..69dd8da 100644 --- a/test/config-plan.test.ts +++ b/test/config-plan.test.ts @@ -38,10 +38,11 @@ test("config planner bootstraps a missing config.yaml with the exact minimal FR3 assert.equal(planResult.result.existedBefore, false); assert.deepEqual( planResult.result.actions.map((action) => action.fieldPath), - ["model.provider", "model.base_url", "model.default"], + ["model.provider", "model.base_url", "model.default", "model.api_key"], ); assert.deepEqual(YAML.parse(planResult.result.nextContents), { model: { + api_key: "${GONKAGATE_API_KEY}", base_url: "https://api.gonkagate.com/v1", default: selectedModelId, provider: "custom", @@ -86,6 +87,7 @@ test("config planner preserves unrelated sections while rewriting only the helpe >; assert.deepEqual((parsed.model as Record) ?? {}, { + api_key: "${GONKAGATE_API_KEY}", base_url: "https://api.gonkagate.com/v1", default: selectedModelId, provider: "custom", @@ -136,6 +138,7 @@ test("config planner leaves legacy root provider/base_url keys untouched while w assert.equal(parsed.provider, "custom"); assert.equal(parsed.base_url, "https://legacy-endpoint.example/v1"); assert.deepEqual(parsed.model, { + api_key: "${GONKAGATE_API_KEY}", base_url: "https://api.gonkagate.com/v1", default: selectedModelId, provider: "custom", diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 9b09b68..728171e 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -40,6 +40,8 @@ test("README captures the shipped helper contract", () => { assert.match(readme, /npx @gonkagate\/hermes-agent-setup/); assert.match(readme, /provider:\s*custom/); assert.match(readme, /https:\/\/api\.gonkagate\.com\/v1/); + assert.match(readme, /GONKAGATE_API_KEY/); + assert.match(readme, /model\.api_key/); assert.match(readme, /~\/\.hermes\/config\.yaml/); assert.match(readme, /~\/\.hermes\/\.env/); assert.match(readme, /v2026\.5\.16/); @@ -116,7 +118,9 @@ test("implementation docs capture the shipped runtime, qualification, and securi 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, /never write the raw key to `config\.yaml`/i); + assert.match(security, /GONKAGATE_API_KEY/); + assert.match(security, /model\.api_key/); assert.match(security, /owner-only `?\.env`? permissions/i); assert.match(security, /mutate `auth\.json` credential\s+pools/i); assert.match(security, /does not scrub provider registries/i); diff --git a/test/e2e-onboard.test.ts b/test/e2e-onboard.test.ts index 86e0fb4..53b202b 100644 --- a/test/e2e-onboard.test.ts +++ b/test/e2e-onboard.test.ts @@ -40,7 +40,7 @@ function createStandardDependencyOverrides( }; } -test("declining the consolidated confirmation cancels the public flow without touching Hermes files", async () => { +test("public flow completes without shared OPENAI_API_KEY confirmation and preserves that key", async () => { const harness = await createHermesIntegrationHarness({ fixture: "review-plan-rich", }); @@ -52,15 +52,11 @@ test("declining the consolidated confirmation cancels the public flow without to }); const stdout = createBufferWriter(); const stderr = createBufferWriter(); - const configPath = resolve(harness.hermesHomeDir, "config.yaml"); const envPath = resolve(harness.hermesHomeDir, ".env"); - const beforeConfig = readFileSync(configPath, "utf8"); - const beforeEnv = readFileSync(envPath, "utf8"); try { await harness.installFakeHermesOnPath(); harness.queueSecretPromptResponses("gp-e2e-secret"); - harness.queueSelectionResponses("cancel"); const result = await run([], { dependencies: harness.createDependencies( @@ -70,13 +66,16 @@ test("declining the consolidated confirmation cancels the public flow without to stdout, }); - assert.equal(result.exitCode, 1); - assert.equal(result.result?.status, "cancelled"); + assert.equal(result.exitCode, 0); + assert.equal(result.result?.status, "success"); assert.match(stdout.contents, /GonkaGate onboarding review/); - assert.match(stdout.contents, /GonkaGate onboarding cancelled\./); + assert.match(stdout.contents, /GonkaGate onboarding completed\./); + assert.doesNotMatch(stdout.contents, /Shared OPENAI_API_KEY takeover/); assert.equal(stderr.contents, ""); - assert.equal(readFileSync(configPath, "utf8"), beforeConfig); - assert.equal(readFileSync(envPath, "utf8"), beforeEnv); + assert.equal( + readFileSync(envPath, "utf8"), + "OPENAI_API_KEY=shared-upstream-key\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nGONKAGATE_API_KEY=gp-e2e-secret\n", + ); } finally { await server.close(); await harness.cleanup(); diff --git a/test/env-plan.test.ts b/test/env-plan.test.ts index 10abaf0..4c21cd5 100644 --- a/test/env-plan.test.ts +++ b/test/env-plan.test.ts @@ -46,13 +46,13 @@ test("env planner creates a new .env when the resolved file is absent", async () }); assert.equal(plan.existedBefore, false); - assert.equal(plan.nextContents, "OPENAI_API_KEY=gp-phase-four-secret\n"); + assert.equal(plan.nextContents, "GONKAGATE_API_KEY=gp-phase-four-secret\n"); } finally { await harness.cleanup(); } }); -test("env planner replaces OPENAI_API_KEY in place without disturbing unrelated key order", async () => { +test("env planner replaces GONKAGATE_API_KEY in place without disturbing unrelated key order", async () => { const harness = await createHermesIntegrationHarness({ fixture: "clean-home", }); @@ -61,7 +61,7 @@ test("env planner replaces OPENAI_API_KEY in place without disturbing unrelated try { await writeFile( envPath, - "FOO=1\nOPENAI_API_KEY=old-secret\nBAR=2\n", + "FOO=1\nGONKAGATE_API_KEY=old-secret\nBAR=2\n", "utf8", ); await harness.installFakeHermesOnPath(); @@ -82,7 +82,7 @@ test("env planner replaces OPENAI_API_KEY in place without disturbing unrelated assert.equal(plan.changed, true); assert.equal( plan.nextContents, - "FOO=1\nOPENAI_API_KEY=gp-phase-four-secret\nBAR=2\n", + "FOO=1\nGONKAGATE_API_KEY=gp-phase-four-secret\nBAR=2\n", ); } finally { await harness.cleanup(); @@ -112,18 +112,18 @@ test("env planner preserves canonical OPENAI_BASE_URL residue outside helper own assert.deepEqual( plan.actions.map((action) => `${action.kind}:${action.key}`), - ["set:OPENAI_API_KEY"], + ["set:GONKAGATE_API_KEY"], ); assert.equal( plan.nextContents, - "OPENAI_BASE_URL=https://api.gonkagate.com/v1\nOPENAI_API_KEY=gp-phase-four-secret\n", + "OPENAI_BASE_URL=https://api.gonkagate.com/v1\nGONKAGATE_API_KEY=gp-phase-four-secret\n", ); } finally { await harness.cleanup(); } }); -test("env planner preserves non-canonical OPENAI_BASE_URL while replacing helper-owned key", async () => { +test("env planner preserves non-canonical OPENAI_BASE_URL and shared OPENAI_API_KEY while writing the dedicated key", async () => { const harness = await createHermesIntegrationHarness({ fixture: "shared-key-conflict", }); @@ -152,7 +152,7 @@ test("env planner preserves non-canonical OPENAI_BASE_URL while replacing helper assert.equal( plan.nextContents, - "FOO=1\nOPENAI_API_KEY=gp-phase-four-secret\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nBAR=2\n", + "FOO=1\nOPENAI_API_KEY=shared-upstream-key\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nBAR=2\nGONKAGATE_API_KEY=gp-phase-four-secret\n", ); } finally { await harness.cleanup(); 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 f807c96..104161a 100644 --- a/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md +++ b/test/fixtures/launch-qualification/valid-multiple/alpha-model-a.md @@ -17,12 +17,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: alpha/model-a + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn 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 5270e34..181771e 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 @@ -17,12 +17,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: qwen/qwen3-235b-a22b-instruct-2507-fp8 + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn 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 5270e34..181771e 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 @@ -17,12 +17,13 @@ model: provider: custom base_url: https://api.gonkagate.com/v1 default: qwen/qwen3-235b-a22b-instruct-2507-fp8 + api_key: ${GONKAGATE_API_KEY} ``` ## Sanitized Env Shape ```dotenv -OPENAI_API_KEY=[REDACTED] +GONKAGATE_API_KEY=[REDACTED] ``` ## Basic Text Turn diff --git a/test/phase-four-orchestration.test.ts b/test/phase-four-orchestration.test.ts index 7e2649c..93ffb09 100644 --- a/test/phase-four-orchestration.test.ts +++ b/test/phase-four-orchestration.test.ts @@ -79,6 +79,7 @@ test("phase-four orchestration can build and apply the mutation plan end to end" }, }, model: { + api_key: "${GONKAGATE_API_KEY}", base_url: "https://api.gonkagate.com/v1", default: "qwen/qwen3-235b-a22b-instruct-2507-fp8", provider: "custom", @@ -91,7 +92,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\nOPENAI_BASE_URL=https://api.other-provider.example/v1\n", + "OPENAI_API_KEY=shared-upstream-key\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nGONKAGATE_API_KEY=gp-phase-four-secret\n", ); } finally { await server.close(); diff --git a/test/qualification/launch-qualification-scripts.test.ts b/test/qualification/launch-qualification-scripts.test.ts index 1fde123..fbbc405 100644 --- a/test/qualification/launch-qualification-scripts.test.ts +++ b/test/qualification/launch-qualification-scripts.test.ts @@ -24,10 +24,10 @@ test("build-artifact writes a checked-in markdown artifact with sanitized excerp writeFileSync( configPath, - "model:\n provider: custom\n base_url: https://api.gonkagate.com/v1\n default: qwen/qwen3-235b-a22b-instruct-2507-fp8\n", + "model:\n provider: custom\n base_url: https://api.gonkagate.com/v1\n default: qwen/qwen3-235b-a22b-instruct-2507-fp8\n api_key: ${GONKAGATE_API_KEY}\n", "utf8", ); - writeFileSync(envPath, "OPENAI_API_KEY=gp-super-secret\n", "utf8"); + writeFileSync(envPath, "GONKAGATE_API_KEY=gp-super-secret\n", "utf8"); writeFileSync( basicLogPath, "assistant: GonkaGate Hermes qualification text ok\nAuthorization: Bearer sk-test-token\n", @@ -73,6 +73,8 @@ test("build-artifact writes a checked-in markdown artifact with sanitized excerp assert.match(artifact, /modelId: qwen\/qwen3-235b-a22b-instruct-2507-fp8/); assert.match(artifact, /recommended: true/); assert.match(artifact, /## Sanitized Config Shape/); + assert.match(artifact, /api_key: \$\{GONKAGATE_API_KEY\}/); + assert.match(artifact, /GONKAGATE_API_KEY=\[REDACTED\]/); assert.match(artifact, /## Basic Text Turn/); assert.match(artifact, /\[REDACTED\]/); assert.doesNotMatch(artifact, /gp-super-secret/); diff --git a/test/review-flow.test.ts b/test/review-flow.test.ts index 83a1189..683ff16 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 shared-key takeover details", async () => { +test("review renderer does not require shared OPENAI_API_KEY confirmation with the dedicated GonkaGate key", async () => { const harness = await createHermesIntegrationHarness({ fixture: "review-plan-rich", }); @@ -68,13 +68,14 @@ test("review renderer produces one consolidated block with shared-key takeover d writePlanResult.result.review.text, /Selected model: qwen\/qwen3-235b-a22b-instruct-2507-fp8/, ); - assert.match( + assert.match(writePlanResult.result.review.text, /GONKAGATE_API_KEY/); + assert.doesNotMatch( writePlanResult.result.review.text, /Shared OPENAI_API_KEY takeover affects/, ); assert.doesNotMatch(writePlanResult.result.review.text, /Scrub matching/); assert.doesNotMatch(writePlanResult.result.review.text, /OPENAI_BASE_URL/); - assert.equal(writePlanResult.result.review.confirmationRequired, true); + assert.equal(writePlanResult.result.review.confirmationRequired, false); } finally { await server.close(); await harness.cleanup(); @@ -131,7 +132,7 @@ test("canonical OPENAI_BASE_URL is not included in review cleanup", async () => } }); -test("declining the consolidated confirmation exits without touching any file", async () => { +test("shared OPENAI_API_KEY state no longer prompts before writing the dedicated GonkaGate key", async () => { const harness = await createHermesIntegrationHarness({ fixture: "review-plan-rich", }); @@ -143,8 +144,6 @@ test("declining the consolidated confirmation exits without touching any file", }); const configPath = resolve(harness.hermesHomeDir, "config.yaml"); const envPath = resolve(harness.hermesHomeDir, ".env"); - const beforeConfig = readFileSync(configPath, "utf8"); - const beforeEnv = readFileSync(envPath, "utf8"); try { await harness.installFakeHermesOnPath(); @@ -162,9 +161,6 @@ test("declining the consolidated confirmation exits without touching any file", if (!writePlanResult.ok) { return; } - - harness.queueSelectionResponses("cancel"); - const executionResult = await executePhaseFourWritePlan( writePlanResult.result, harness.createDependencies( @@ -172,9 +168,13 @@ test("declining the consolidated confirmation exits without touching any file", ), ); - assert.equal(executionResult.status, "cancelled"); - assert.equal(readFileSync(configPath, "utf8"), beforeConfig); - assert.equal(readFileSync(envPath, "utf8"), beforeEnv); + assert.equal(executionResult.status, "written"); + assert.deepEqual(harness.readPromptInvocations().selectOptions, []); + assert.match(readFileSync(configPath, "utf8"), /\$\{GONKAGATE_API_KEY\}/); + assert.equal( + readFileSync(envPath, "utf8"), + "OPENAI_API_KEY=shared-upstream-key\nOPENAI_BASE_URL=https://api.other-provider.example/v1\nGONKAGATE_API_KEY=gp-phase-four-secret\n", + ); } finally { await server.close(); await harness.cleanup(); diff --git a/test/review-plan-builder.test.ts b/test/review-plan-builder.test.ts index b019a55..4219187 100644 --- a/test/review-plan-builder.test.ts +++ b/test/review-plan-builder.test.ts @@ -21,13 +21,13 @@ test("builder produces one deterministic pre-write review plan", async () => { assert.deepEqual( reviewPlanResult.result.plan.confirmationItems.map((item) => item.kind), - ["shared_openai_key_takeover"], + [], ); assert.deepEqual( reviewPlanResult.result.plan.plannedConfigScrubs .map((scrub) => scrub.fieldPath) .sort(), - ["model.api", "model.api_key", "model.api_mode"], + ["model.api", "model.api_mode"], ); assert.deepEqual(reviewPlanResult.result.plan.blockingFindings, []); } finally {