From d62a53637d621df36dd9d3251a1218e5255643cd Mon Sep 17 00:00:00 2001 From: tungmv Date: Wed, 4 Nov 2026 10:02:26 +0700 Subject: [PATCH] fix(config): use options.apiKey as the provider credential (#28) The endpoint builder read auth.json/auth-overlay.json only, so a provider whose key lived in the documented `options.apiKey` slot reached the upstream with `Authorization: Bearer ` (empty). The provider answers [code=missing_api_key], which reads as a client bug rather than a config key that was dropped, and the turn burns a round trip before failing. Two arms were broken, and both presented identically, so both are covered: - a literal key was ignored (auth-only lookup); - a `{env:NAME}` reference was forwarded verbatim, because ocserve has no config substitution at all. Config `options.apiKey` now follows upstream precedence (config first, auth layer as the fallback) and is interpolated: `{env:NAME}` and `{file:PATH}`, the latter resolved relative to the declaring config rather than the process cwd. `options/baseURL` is interpolated too (a per-environment gateway host). This mattered beyond a single provider because opencode 2.x no longer writes auth.json; keys live in the `credential` table, so on a 2.x install every config-declared provider was unauthenticated unless its key was duplicated by hand into auth-overlay.json. Three deliberate deviations from upstream, all fail-loud: - an unset `{env:NAME}` expands to "" (upstream parity) but logs the field path; silence here is what made #28 an afternoon instead of a minute; - a provider whose configured key resolves empty is named at boot, without the secret; - only the two documented forms expand. An unknown `{form:...}` is passed through byte for byte instead of truncated. `OCSERVE_CONFIG_INTERPOLATE=0` restores the verbatim behaviour, declared in the env contract with docs/ENVIRONMENT.md regenerated. Tests: runtime::provider_key_tests x9, plus a live wire repro against a capture sink that logs the Authorization header (pre-fix `Bearer `, post-fix `Bearer secret-from-env` for `{env:FAKE_API_KEY}` and `Bearer sk-literal` for a literal). Gates: fmt, clippy -D warnings, check-guards (rule 58 included), check-docs, check-matrix. TRACEABILITY row K-PROVIDER-KEY. Refs #28 --- CHANGELOG.md | 15 + TRACEABILITY.md | 1 + crates/ocserve-cli/src/runtime.rs | 354 +++++++++++++++++++++++- crates/ocserve-core/src/env_contract.rs | 7 + docs/ENVIRONMENT.md | 1 + 5 files changed, 372 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b46d2..33f8cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ differs is a named `D-*` row in the ledger at `PLAN.md §18`. ## Unreleased ### Fixed +- **A provider's `options.apiKey` was never used as the credential (issue #28).** + The endpoint builder read `auth.json`/`auth-overlay.json` only, so every + provider whose key lived in the documented `options.apiKey` slot sent + `Authorization: Bearer ` (empty) to the upstream, which answers + `missing_api_key` and reads like a client bug. opencode 2.x no longer writes + `auth.json` (keys live in the `credential` table), so this hit every + config-declared provider on a 2.x install. A literal key was dropped as + silently as a `{env:...}` reference. Config `options.apiKey` now follows + upstream precedence (config first, auth layer as the fallback) and is + interpolated: `{env:NAME}` and `{file:PATH}`, with `{file:}` resolved relative + to the declaring config. `options/baseURL` is interpolated too. An unset + `{env:NAME}` still expands to the empty string (upstream parity) but logs the + field path, and a provider whose configured key resolves empty is named at + boot without the secret. `OCSERVE_CONFIG_INTERPOLATE=0` restores the old + verbatim behaviour for operators sharing one config with another server. - **Credential rename and delete were no-ops.** `PATCH /api/credential/{id}` and `DELETE /api/credential/{id}` returned 204 without touching the store, so a client renaming a credential saw success and no change. Both now edit the diff --git a/TRACEABILITY.md b/TRACEABILITY.md index 75248b9..8384a72 100644 --- a/TRACEABILITY.md +++ b/TRACEABILITY.md @@ -34,6 +34,7 @@ | K-WATCHDOG2 | Two-phase provider watchdog (gateway keep-alive hangs) | The 2026-10-12 report: session `ses_001a12c733...` (mimo-v2.6-flash / opencode-go) stopped continuing — 4 stacked user messages, every turn killed at exactly 120s (`provider stream stalled: no data for 120s`; 2026-10-11 20:17/20:19, 2026-10-12 07:51/09:31). Live probe with the exact composite UA + key: the gateway answers headers immediately then streams `: keep-alive` comment pings every 5s while the free tier queues the model; 6 MB request first-data 13-17s normal, one probe 67s+ then a completion, 0.25/0.5/1/3 MB always completed — keep-alives satisfied the old socket-liveness read, and the first-real-event wait used the 120s stream budget. Fix: keep-alive frames are NOT data; first real event budget = `provider_ttfb()` (OCSERVE_PROVIDER_TTFB_SECS → OCSERVE_PROVIDER_STALL_SECS → default 300s), everything after stays `provider_stall()` (120s) — genuinely silent sockets still trip at 120s; compaction stream loop got the same split | `stall_gateway.rs` keep-alive-only-forever: trips at the TTFB budget, lock+task state released; `stall_gateway_data.rs` first-data at 3s under stream=1s/TTFB=6s: turn completes (+ negative control: neuter the phase split → red, proven) | green (476 tests, guards 26/26, replay 26/0) | | K-FREETIER-TRANSPORT | opencode.ai requests pass the free-tier gate: BoringSSL TLS stack + composite UA + tools array; other providers keep rustls | `zen_live` trio (nightly, live: positive / text-only / negative), end-to-end prompt through a real server (`ZEN_E2E`), 5-stack replay evidence in `bench/zen-probe` | green | | K-STREAM-FINISH-CONTENT | A chunk carrying both content and `finish_reason` produces the content event before Done (no dropped reply) | `finish_with_content_tests` (text, tool-call, split-chunk order) | green | +| K-PROVIDER-KEY | Config-declared provider credentials follow upstream precedence (`options.apiKey`, `{env:…}`/`{file:…}` interpolated, then the auth layer) — issue #28. Before this the endpoint builder read `auth.json`/`auth-overlay.json` ONLY, so a provider whose key lived in the documented `options.apiKey` slot (the shape opencode 2.x emits, since 2.x stopped writing auth.json) reached the upstream with `Bearer ` — empty, after `unwrap_or("")` — while `/config/providers` displayed the key. The provider then answered `missing_api_key`, which reads as a client bug. A literal key was dropped as silently as `{env:…}`, so both arms are covered. Deliberate additions: an unset `{env:…}` expands to "" (upstream parity) but logs the field path, and a provider whose configured key resolves empty is named at boot (no secret in the message). `{file:}` resolves relative to the declaring config, not the process cwd. `options/baseURL` is interpolated too. | `runtime::provider_key_tests` ×9 (`literal_config_apikey_reaches_the_endpoint`, `env_reference_in_config_apikey_is_interpolated`, `unset_env_reference_warns_and_expands_empty`, `other_placeholder_forms_are_left_verbatim`, `file_reference_resolves_relative_to_the_config_dir`, `missing_file_reference_warns_with_the_resolved_path`, `plain_strings_are_returned_untouched`, `auth_layer_is_the_fallback_and_config_wins`, `interpolation_kill_switch_returns_the_raw_value`) + live wire repro: fake upstream capture sink logs `Authorization`, ocserve pre-fix `Bearer ` / post-fix `Bearer secret-from-env` for `{env:FAKE_API_KEY}` and `Bearer sk-literal` for a literal | green | | K-MODEL-OFFER | The served model list matches freeze: `deprecated` models are never offered, `alpha` only under `OPENCODE_ENABLE_EXPERIMENTAL_MODELS`, and the effective default cannot select a model the picker no longer lists | `runtime::tests::served_models_exclude_deprecated_and_alpha`, `experimental_flag_admits_alpha_but_never_deprecated`, `default_model_skips_a_deprecated_state_entry`; live check on a throwaway port: opencode 46 -> 9 models, matching upstream 1.18.31 | green | | K-FREETIER-UPSELL | A spent opencode free allowance is terminal and actionable: classified from the provider body and surfaced as upstream's upsell in ONE typed `APIError` (durable and live), never a silent raw 429 | `ocserve-llm` free_tier classifier tests (real body; plain 429/500 excluded), `prompt::free_tier_tests`, `free_tier_error` e2e (one durable typed event; red as `UnknownError` when the marker is dropped), contract-validated `EventSessionError`; live journal 2026-10-25T15:46/15:47 (three silent attempts) and 17:26:58 (typed live, UnknownError durable) | green | | K-BOUNDED-PROMPT-LOAD | Prompt history is loaded as a provable suffix (O(8 MB)) instead of the whole session (85 MB on the deep session); equivalence is machine-checked, with a full-load fallback and a kill switch | `crates/ocserve-store/tests/prompt_window.rs` (9 window shapes, incl. the multi-compaction late-insert anchor regression), rule 57, `prompt.rs::bounded_prompt_load_tests` (10 equivalence cases incl. planted control), `bounded_prompt_load_matches_real_corpus` (interleaved, warm-up discarded; `bench/prompt-window/REPORT.md`; real sessions: 16,616-msg deep session 4.6-5.2 s → 1.45-1.59 s; compacted session 117 ms → 22 ms; a dense mid-size session 2.89 → 2.56 s where the window is most of the data; emitted messages byte-identical in every case), `OCSERVE_PROMPT_HISTORY_UNBOUNDED=1` | green | diff --git a/crates/ocserve-cli/src/runtime.rs b/crates/ocserve-cli/src/runtime.rs index 11d1b32..1db1137 100644 --- a/crates/ocserve-cli/src/runtime.rs +++ b/crates/ocserve-cli/src/runtime.rs @@ -48,6 +48,136 @@ fn read_json(path: &Path) -> Result { serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display())) } +/// `OCSERVE_CONFIG_INTERPOLATE=0` kill switch (env contract, rule 58). +/// +/// A kill switch rather than a feature flag on purpose: interpolation turns a +/// config file into code (any `{file:…}` becomes readable to the provider +/// boundary), so an operator needs a way to keep ocserve from resolving +/// references without patching the config every other server shares. Unset +/// means ON, matching the contract's "on, 0 disables" convention. +fn interpolation_enabled() -> bool { + !matches!( + std::env::var("OCSERVE_CONFIG_INTERPOLATE").as_deref(), + Ok("0") + ) +} + +/// Expand `{env:NAME}` and `{file:PATH}` in a config string, upstream +/// config.ts substitution semantics (issue #28). +/// +/// Upstream resolves these in EVERY config value before the provider is built, +/// so a config-declared provider can keep its key out of the file: +/// +/// ```json +/// "options": { "apiKey": "{env:OPENROUTER_API_KEY}" } +/// ``` +/// +/// Without this, ocserve forwarded the placeholder verbatim as the bearer +/// token (the provider then answers 401) or handed back an empty key, and the +/// failure looked like a client bug rather than a dropped config key. +/// +/// Deviations from upstream, all deliberate and all fail-loud: +/// - an unset variable expands to the empty string exactly as upstream, but it +/// also logs a warning naming the path — a silently empty credential was the +/// whole 2026-11-03 debugging session behind #28; +/// - `{file:}` is read relative to the config that declared it, so a relative +/// path works (upstream resolves against the process cwd); +/// - only the two documented forms are expanded. A stray `{env:` is left +/// verbatim rather than silently truncated. +pub(crate) fn expand_config_value( + raw: &str, + base: Option<&Path>, + field: &str, + warnings: &mut Vec, +) -> String { + if !interpolation_enabled() { + return raw.to_string(); + } + if !raw.contains("{env:") && !raw.contains("{file:") { + return raw.to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + let tail = &rest[start..]; + let Some(end) = tail.find('}') else { + out.push_str(tail); + return out; + }; + let token = &tail[..=end]; + let inner = &tail[1..end]; + if let Some(name) = inner.strip_prefix("env:") { + match std::env::var(name) { + Ok(v) => out.push_str(&v), + Err(_) => { + warnings.push(format!( + "{field}: environment variable {name} is not set — expanding {{env:{name}}} to an empty string" + )); + } + } + } else if let Some(path) = inner.strip_prefix("file:") { + // Relative paths resolve against the declaring config's directory + // (upstream uses the process cwd there; the config-relative form is + // the one that keeps a repo-local config working from any cwd). + let resolved = match base { + Some(b) if !Path::new(path).is_absolute() => b.join(path), + _ => std::path::PathBuf::from(path), + }; + match std::fs::read_to_string(&resolved) { + Ok(v) => out.push_str(v.trim_end_matches(['\n', '\r'])), + Err(_) => warnings.push(format!( + "{field}: config file reference {path} is missing or unreadable ({}) — expanding {{file:{path}}} to an empty string", + resolved.display() + )), + } + } else { + out.push_str(token); + } + rest = &tail[end + 1..]; + } + out.push_str(rest); + out +} + +/// The config-provider credential, in upstream precedence order: +/// explicit `options.apiKey` (interpolated) wins, then the auth layer +/// (`auth.json` / `auth-overlay.json`, which W5 lets the overlay win). +/// +/// Issue #28: this used to read the auth layer ONLY, so every provider whose +/// key lived in `options.apiKey` — the documented shape, and the one opencode +/// 2.x installs emit because it no longer writes auth.json — reached the +/// upstream with an empty bearer token while `/config/providers` happily +/// showed the key. A literal key was dropped just as silently as `{env:…}`. +fn config_provider_key( + pid: &str, + provider: &Value, + auth: &Value, + base: Option<&Path>, + warnings: &mut Vec, +) -> String { + let from_config = provider + .pointer("/options/apiKey") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|raw| { + expand_config_value( + raw, + base, + &format!("provider.{pid}.options.apiKey"), + warnings, + ) + }) + .filter(|s| !s.is_empty()); + if from_config.is_some() { + return from_config.unwrap_or_default(); + } + auth.pointer(&format!("/{pid}/key")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + /// Global config read with upstream parity (config.ts: missing file → empty, /// unreadable/corrupt → "using defaults" + continue). Found by CI 2026-10-08: /// `serve` on a bare machine (no `~/.config/opencode/opencode.json`) died at @@ -1221,20 +1351,68 @@ impl Runtime { } } // config providers without auth entries (baseURL + env-less key) + // + // Issue #28: the key now follows upstream precedence — + // `options.apiKey` (interpolated: `{env:…}` / `{file:…}`) first, then + // the auth layer. Reading the auth layer ONLY meant a provider whose key + // lived in the documented `options.apiKey` slot sent `Bearer ` (empty) + // to the upstream, which reads as a client bug (`missing_api_key`), not + // as a config key that was dropped. + let mut key_warnings: Vec = Vec::new(); + let cfg_dir = Path::new(&home).join(".config/opencode"); if let Some(provs) = cfg.get("provider").and_then(|v| v.as_object()) { for (pid, p) in provs { + // `{env:…}` in baseURL is upstream-supported too (a gateway host + // per environment); interpolate before the contains_key guard so + // an auth-less provider with an env baseURL is not skipped. + let base = p + .pointer("/options/baseURL") + .and_then(|v| v.as_str()) + .map(|raw| { + expand_config_value( + raw, + Some(&cfg_dir), + &format!("provider.{pid}.options.baseURL"), + &mut key_warnings, + ) + }); if endpoints.contains_key(pid) { + // Still interpolate the key when auth already supplied a base + // URL: the auth arm above only stores the raw auth key, and a + // config `options.apiKey` must win over it (upstream + // precedence) — leaving the raw `{env:…}` on the wire is the + // #28 bug in its other branch. + let key = config_provider_key(pid, p, &auth, Some(&cfg_dir), &mut key_warnings); + if !key.is_empty() + && let Some(ep) = endpoints.get_mut(pid) + { + ep.1 = key; + } continue; } - if let Some(base) = p.pointer("/options/baseURL").and_then(|v| v.as_str()) { - let key = auth - .pointer(&format!("/{pid}/key")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - endpoints.insert(pid.clone(), (base.to_string(), key.to_string())); + if let Some(base) = base { + let key = config_provider_key(pid, p, &auth, Some(&cfg_dir), &mut key_warnings); + endpoints.insert(pid.clone(), (base, key)); } } } + for w in key_warnings { + tracing::warn!("{w}"); + } + // A base URL with no credential used to be indistinguishable from a + // provider that never had one, and the turn died at the upstream. Name + // it at boot instead (no secret in the message). + for (pid, (_, key)) in &endpoints { + if key.is_empty() + && cfg + .pointer(&format!("/provider/{pid}/options/apiKey")) + .is_some() + { + tracing::warn!( + "provider {pid} has a configured apiKey but it resolved to an empty credential — requests will be sent unauthenticated (check the {{{{env:…}}}} variable it references)" + ); + } + } // B2 (K-MODEL-STATE): opencode public-tier endpoint (pure fn below). if let Some(pair) = opencode_public_endpoint(&cfg, &auth, cache) { @@ -1962,3 +2140,167 @@ mod registry_catalog_tests { assert_eq!(ep.1, "public", "keyless default"); } } + +#[cfg(test)] +mod provider_key_tests { + use super::*; + + /// Issue #28: a config-declared provider kept its key in the documented + /// `options.apiKey` slot and ocserve sent `Bearer ` (empty) to the + /// upstream, because the endpoint builder read the auth layer only. + /// The literal form goes first: it isolates "auth-only lookup" from + /// "no interpolation", which are two different bugs that presented + /// identically at the provider (2026-11-03 live repro). + #[test] + fn literal_config_apikey_reaches_the_endpoint() { + let mut warnings = Vec::new(); + let provider = json!({"options": {"baseURL": "http://x/v1", "apiKey": "sk-literal"}}); + let key = config_provider_key("p", &provider, &json!({}), None, &mut warnings); + assert_eq!(key, "sk-literal", "config key must win over an empty auth"); + assert!(warnings.is_empty(), "a literal key warns about nothing"); + } + + /// The shape every opencode 2.x install uses: the file holds a reference, + /// the secret lives in the environment. + #[test] + fn env_reference_in_config_apikey_is_interpolated() { + let name = format!("FAKE_TEST_KEY_{}", std::process::id()); + // SAFETY: edition 2024 marks set_var unsafe (parallel-test race); the + // value is namespaced by pid and immediately consumed below. + unsafe { std::env::set_var(&name, "sk-from-env") }; + let mut warnings = Vec::new(); + let provider = json!({"options": {"apiKey": format!("{{env:{name}}}")}}); + let key = config_provider_key("p", &provider, &json!({}), None, &mut warnings); + unsafe { std::env::remove_var(&name) }; + assert_eq!(key, "sk-from-env", "the placeholder must not ride the wire"); + assert!(warnings.is_empty()); + } + + /// An unset variable still expands to "" (upstream parity) but must be + /// visible: silence here is what made #28 an afternoon instead of a minute. + #[test] + fn unset_env_reference_warns_and_expands_empty() { + let mut warnings = Vec::new(); + let provider = json!({"options": {"apiKey": "{env:FAKE_TEST_VAR_DEFINITELY_UNSET}"}}); + let key = config_provider_key("p", &provider, &json!({}), None, &mut warnings); + assert_eq!(key, "", "upstream expands an unset variable to empty"); + assert_eq!(warnings.len(), 1, "but never silently: {:?}", warnings); + assert!( + warnings[0].contains("FAKE_TEST_VAR_DEFINITELY_UNSET"), + "the warning must name the variable: {}", + warnings[0] + ); + } + + /// Only the two documented forms are expanded. A key written in some other + /// `{form:…}` dialect is passed through byte-for-byte rather than truncated: + /// mangling it would hide the mistake, and the upstream's 401 is a clearer + /// signal than a half-eaten credential. + #[test] + fn other_placeholder_forms_are_left_verbatim() { + let mut warnings = Vec::new(); + let provider = json!({"options": {"apiKey": "{vault:path/secret}"}}); + assert_eq!( + config_provider_key("p", &provider, &json!({}), None, &mut warnings), + "{vault:path/secret}", + "an unknown form is neither expanded nor truncated" + ); + assert!( + warnings.is_empty(), + "nothing to warn about: we never claimed it" + ); + } + + /// `{file:}` keeps the secret out of the config file; relative paths resolve + /// against the declaring config, not the process cwd. + #[test] + fn file_reference_resolves_relative_to_the_config_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("creds"), "sk-from-file\n").unwrap(); + let mut warnings = Vec::new(); + let provider = json!({"options": {"apiKey": "{file:creds}"}}); + let key = config_provider_key("p", &provider, &json!({}), Some(dir.path()), &mut warnings); + assert_eq!( + key, "sk-from-file", + "trailing newline trimmed, path relative" + ); + assert!(warnings.is_empty()); + } + + #[test] + fn missing_file_reference_warns_with_the_resolved_path() { + let dir = tempfile::tempdir().unwrap(); + let mut warnings = Vec::new(); + let out = expand_config_value("{file:nope}", Some(dir.path()), "f", &mut warnings); + assert_eq!(out, ""); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("nope"), "{}", warnings[0]); + } + + #[test] + fn plain_strings_are_returned_untouched() { + let mut warnings = Vec::new(); + assert_eq!( + expand_config_value("sk-plain{}", None, "f", &mut warnings), + "sk-plain{}", + "a stray brace outside a known form is preserved" + ); + assert_eq!( + expand_config_value("{env:truncated", None, "f", &mut warnings), + "{env:truncated" + ); + assert!(warnings.is_empty()); + } + + /// The kill switch must restore the pre-fix behaviour for an operator who + /// shares one config file with another server and does not want ocserve + /// resolving references out of it. + #[test] + fn interpolation_kill_switch_returns_the_raw_value() { + let name = format!("FAKE_TEST_SWITCH_{}", std::process::id()); + // SAFETY: edition 2024 marks set_var unsafe (parallel-test race). + // The name is namespaced by pid and both vars are restored below. + unsafe { + std::env::set_var(&name, "sk-from-env"); + std::env::set_var("OCSERVE_CONFIG_INTERPOLATE", "0"); + } + let mut warnings = Vec::new(); + let raw = format!("{{env:{name}}}"); + let out = expand_config_value(&raw, None, "f", &mut warnings); + unsafe { + std::env::remove_var("OCSERVE_CONFIG_INTERPOLATE"); + std::env::remove_var(&name); + } + assert_eq!(out, raw, "kill switch must not resolve anything"); + assert!(warnings.is_empty()); + } + + /// The auth layer is still the fallback, and config still wins — that is the + /// W5 precedence the admin `PUT /auth/{providerID}` route relies on. + #[test] + fn auth_layer_is_the_fallback_and_config_wins() { + let mut warnings = Vec::new(); + let provider = json!({"options": {"apiKey": "sk-config"}}); + assert_eq!( + config_provider_key( + "p", + &provider, + &json!({"p": {"key": "sk-auth"}}), + None, + &mut warnings + ), + "sk-config" + ); + let bare = json!({"options": {"baseURL": "http://x"}}); + assert_eq!( + config_provider_key( + "p", + &bare, + &json!({"p": {"key": "sk-auth"}}), + None, + &mut warnings + ), + "sk-auth" + ); + } +} diff --git a/crates/ocserve-core/src/env_contract.rs b/crates/ocserve-core/src/env_contract.rs index 8ce4274..4d2ec92 100644 --- a/crates/ocserve-core/src/env_contract.rs +++ b/crates/ocserve-core/src/env_contract.rs @@ -455,6 +455,13 @@ pub const CONTRACT: &[Var] = &[ "milliseconds (min 50)", "Budget for one sift compression.", ), + op( + "Prompts and providers", + "OCSERVE_CONFIG_INTERPOLATE", + "on", + "0 disables", + "Expand `{env:NAME}` and `{file:PATH}` in config provider `options` values (upstream config.ts substitution). With this off a `{env:...}` apiKey reaches the provider verbatim, which is the issue #28 failure mode.", + ), op( "Config, sync and platform", "OCSERVE_SHELL", diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 1a24239..6315cf8 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -46,6 +46,7 @@ for the normal behaviour. | `OCSERVE_ZEN_KEYLESS` | on | 0 disables | Install the keyless `Bearer public` endpoint for opencode free models. | | `OCSERVE_CONTINUE_ON_DENY` | off (a deny stops the turn) | 1 enables | Keep the turn going after a denied tool call. | | `OCSERVE_LOOP_GUARD` | on | 0 disables | Repeated/oscillating tool-call detection and its permission ask. | +| `OCSERVE_CONFIG_INTERPOLATE` | on | 0 disables | Expand `{env:NAME}` and `{file:PATH}` in config provider `options` values (upstream config.ts substitution). With this off a `{env:...}` apiKey reaches the provider verbatim, which is the issue #28 failure mode. | ## Plugins and MCP