Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: update configuration to use ${ENV_VAR} syntax for environme… (#17)

* refactor: update configuration to use ${ENV_VAR} syntax for environment variable references

- Changed auth_token_env_ref to auth_token in endpoint configurations across README, example YAML, and documentation.
- Enhanced support for environment variable expansion in configuration files.
- Removed deprecated auth_token_env_ref references from code and tests, ensuring consistency in how tokens are handled.
- Added tests for environment variable expansion functionality.

* refactor: simplify environment variable expansion in configuration handling

- Replaced the previous ExpandEnvStrings function with ReadExpandedConfig to read and expand environment variables directly from the YAML config file.
- Improved handling of different file extensions and ensured compatibility with viper's configuration reading.
- Removed redundant environment variable expansion calls from the initConfig and loadRuntimeSnapshot functions.

* test: enhance ReadExpandedConfig tests for YAML configuration handling

- Updated tests to validate the ReadExpandedConfig function, ensuring proper expansion of environment variables from a YAML file.
- Added tests for handling literal dollar signs and unset variables.
- Included a test case for file not found scenarios to improve error handling.

* fix: use strict ${ENV_VAR}-only expander and warn on unset variables

Address PR #17 review feedback:
- Replace os.ExpandEnv with a regex-based expander that only matches
the ${NAME} form, leaving bare $VAR and bcrypt hashes untouched.
- Warn (not silently ignore) when ${ENV_VAR} references unset variables.
- Fix TestPatchInitConfigWithSetup_AppliesOverrides by using prefix-based
line replacement so it works with the new ${OPENAI_API_KEY} default.
- Remove .cursor/worktrees.json from tracking and add .cursor/ to .gitignore.

Made-with: Cursor

authored by

yiplee and committed by
GitHub
350c4255 b6be4309

+352 -464
+1
.gitignore
··· 4 4 config.yaml 5 5 6 6 .ai 7 + .cursor/ 7 8 go.work 8 9 go.work.sum 9 10 dump
+7 -7
README.md
··· 227 227 endpoints: 228 228 - name: "Main" 229 229 url: "http://127.0.0.1:8787" 230 - auth_token_env_ref: "MISTER_MORPH_ENDPOINT_MAIN_TOKEN" 230 + auth_token: "${MISTER_MORPH_ENDPOINT_MAIN_TOKEN}" 231 231 ``` 232 232 233 233 Open: ··· 432 432 - `MISTER_MORPH_TOOLS_URL_FETCH_ENABLED` 433 433 - `MISTER_MORPH_TOOLS_URL_FETCH_MAX_BYTES` 434 434 435 - `auth_profiles.*.credential.secret_ref` is also a direct environment variable name (example: `JSONBILL_API_KEY`). 435 + All string values in config support `${ENV_VAR}` syntax for environment variable expansion (e.g. `api_key: "${OPENAI_API_KEY}"`). 436 436 437 437 Key meanings (see `assets/config/config.example.yaml` for the canonical list): 438 438 - Core: `llm.provider` selects the backend. Most providers use `llm.endpoint`/`llm.api_key`/`llm.model`. Optional defaults `llm.temperature`, `llm.reasoning_effort`, and `llm.reasoning_budget_tokens` are forwarded to `uniai` only when set. Azure uses `llm.azure.deployment` for deployment name, while endpoint/key are still read from `llm.endpoint` and `llm.api_key`. Bedrock uses `llm.bedrock.*`. `llm.tools_emulation_mode` controls tool-call emulation for models without native tool calling (`off|fallback|force`). `llm.profiles` defines named profile overrides, and `llm.routes` routes semantic purposes such as `main_loop`, `addressing`, `heartbeat`, `plan_create`, and `memory_draft`. 439 - - LLM secret refs: secret-like fields in `llm` and `llm.profiles` accept sibling `*_ref` keys. The value of `*_ref` is the environment variable name, not the secret itself. If both plaintext and `*_ref` are set, `*_ref` wins. If `*_ref` points to a missing or empty env var, startup fails fast. Example: 439 + - LLM secrets: use `${ENV_VAR}` syntax in any string field to reference environment variables. Example: 440 440 ```yaml 441 441 llm: 442 - api_key_ref: OPENAI_API_KEY 442 + api_key: "${OPENAI_API_KEY}" 443 443 profiles: 444 444 reasoning: 445 445 provider: xai 446 446 model: grok-4.1-fast-reasoning 447 - api_key_ref: XAI_API_KEY 447 + api_key: "${XAI_API_KEY}" 448 448 ``` 449 - - LLM precedence: for config-sourced values, precedence is `CLI flag > MISTER_MORPH_* env > config.yaml > default`. After that, for each secret-like field pair such as `api_key` / `api_key_ref`, the selected `*_ref` overrides the plaintext sibling. 449 + - LLM precedence: for config-sourced values, precedence is `CLI flag > MISTER_MORPH_* env > config.yaml > default`. 450 450 - Logging: `logging.level` (`info` shows progress; `debug` adds thoughts), `logging.format` (`text|json`), plus `logging.include_thoughts` and `logging.include_tool_params` (redacted). 451 451 - Loop: `max_steps` limits tool-call rounds; `parse_retries` retries invalid JSON; `max_token_budget` is a cumulative token cap (0 disables); `timeout` is the overall run timeout. 452 452 - Skills: `skills.enabled` controls whether skills are used; `file_state_dir` + `skills.dir_name` define the default skills root; `skills.load=[]` loads all discovered skills, otherwise it loads only listed skills (unknown names are ignored). 453 453 - Tools: all tool toggles live under `tools.*` (e.g. `tools.bash.enabled`, `tools.url_fetch.enabled`) with per-tool limits and timeouts. 454 - - Auth profiles: `secrets.allow_profiles` is the runtime allowlist. `auth_profiles.<id>.credential.secret_ref` points directly to an environment variable. If at least one allowlisted auth profile is configured, `bash` still works but `curl` is denied by default; authenticated HTTP should go through `url_fetch + auth_profile`. 454 + - Auth profiles: `secrets.allow_profiles` is the runtime allowlist. `auth_profiles.<id>.credential.secret` holds the secret value (use `${ENV_VAR}` to reference env vars). If at least one allowlisted auth profile is configured, `bash` still works but `curl` is denied by default; authenticated HTTP should go through `url_fetch + auth_profile`. 455 455 456 456 ## Star History 457 457
+13 -16
assets/config/config.example.yaml
··· 3 3 # Usage: 4 4 # ./mistermorph run --config ./assets/config/config.example.yaml --task "..." 5 5 # 6 + # All string values support ${ENV_VAR} syntax for environment variable expansion. 7 + # Example: api_key: "${OPENAI_API_KEY}" 8 + # 6 9 # Env vars (override config): 7 10 # - Preferred: MISTER_MORPH_LLM_PROVIDER, MISTER_MORPH_LLM_ENDPOINT, MISTER_MORPH_LLM_MODEL, MISTER_MORPH_LLM_API_KEY 8 11 # - Cloudflare: MISTER_MORPH_LLM_CLOUDFLARE_ACCOUNT_ID, MISTER_MORPH_LLM_CLOUDFLARE_API_TOKEN ··· 17 20 model: "gpt-5.4" 18 21 # Base URL for providers that use OpenAI-compatible endpoints. 19 22 endpoint: "https://api.openai.com" 20 - # Provider API key. Prefer env var to avoid committing secrets. 21 - api_key: "" # or set via MISTER_MORPH_LLM_API_KEY 22 - # Optional env var name for the API key. If set, this takes precedence over api_key. 23 - # api_key_ref: OPENAI_API_KEY 23 + # Provider API key. Use ${ENV_VAR} to avoid committing secrets. 24 + api_key: "${OPENAI_API_KEY}" # or set via MISTER_MORPH_LLM_API_KEY 24 25 # Per-LLM HTTP request timeout (0 uses provider default). 25 26 request_timeout: "90s" 26 27 # Optional default temperature. If empty/unset, do not call uniai.WithTemperature(...). ··· 38 39 deployment: "" # Azure OpenAI deployment name 39 40 # Provider-specific settings for AWS Bedrock. 40 41 bedrock: 41 - aws_key: "" 42 - # aws_key_ref: AWS_ACCESS_KEY_ID 43 - aws_secret: "" 44 - # aws_secret_ref: AWS_SECRET_ACCESS_KEY 42 + aws_key: "${AWS_ACCESS_KEY_ID}" 43 + aws_secret: "${AWS_SECRET_ACCESS_KEY}" 45 44 region: "" 46 45 model_arn: "" 47 46 # Provider-specific settings for Cloudflare Workers AI. 48 47 cloudflare: 49 48 account_id: "" 50 - api_token: "" 51 - # api_token_ref: CLOUDFLARE_API_TOKEN 49 + api_token: "${CLOUDFLARE_API_TOKEN}" 52 50 # Optional named LLM profiles. Each profile inherits top-level llm.* by default. 53 51 # profiles: 54 52 # cheap: ··· 56 54 # reasoning: 57 55 # provider: xai 58 56 # model: "grok-4.1-fast-reasoning" 59 - # # api_key_ref: XAI_API_KEY 57 + # api_key: "${XAI_API_KEY}" 60 58 # reasoning_effort: "high" 61 59 # Optional route map. Supported purposes: 62 60 # main_loop | addressing | heartbeat | plan_create | memory_draft ··· 115 113 116 114 # Auth profiles are referenced by tools via `auth_profile: "<id>"`. 117 115 # 118 - # NOTE: This is a sample. Do NOT store secret values here. `secret_ref` is the environment variable name. 116 + # NOTE: Use ${ENV_VAR} syntax for secrets to avoid committing them to config files. 119 117 auth_profiles: 120 118 # jsonbill: 121 119 # credential: 122 120 # kind: api_key 123 - # secret_ref: JSONBILL_API_KEY 121 + # secret: "${JSONBILL_API_KEY}" 124 122 # allow: 125 123 # url_prefixes: ["https://api.jsonbill.com/tasks/docs"] 126 124 # methods: ["POST"] ··· 315 313 endpoints: 316 314 - name: "Telegram Instance" 317 315 url: "http://127.0.0.1:8787" 318 - auth_token: ${MISTER_MORPH_ENDPOINT_TELEGRAM_TOKEN} 316 + auth_token: "${MISTER_MORPH_ENDPOINT_TELEGRAM_TOKEN}" 319 317 - name: "Slack Instance" 320 318 url: "http://127.0.0.1:8788" 321 - # use $ENV_VAR syntax to reference environment variables. 322 - auth_token: ${MISTER_MORPH_ENDPOINT_SLACK_TOKEN} 319 + auth_token: "${MISTER_MORPH_ENDPOINT_SLACK_TOKEN}" 323 320 324 321 # Telegram bot mode (`mistermorph telegram`). 325 322 telegram:
+2 -12
cmd/mistermorph/consolecmd/serve.go
··· 48 48 Name string `mapstructure:"name"` 49 49 URL string `mapstructure:"url"` 50 50 // AuthToken is the auth token for the runtime endpoint. 51 - // Use $ENV_VAR syntax to reference environment variables. 51 + // Use ${ENV_VAR} syntax to reference environment variables. 52 52 // Example: 53 - // auth_token: $MISTER_MORPH_ENDPOINT_AUTH_TOKEN 53 + // auth_token: ${MISTER_MORPH_ENDPOINT_AUTH_TOKEN} 54 54 AuthToken string `mapstructure:"auth_token"` 55 - // Auth token is read from process environment via auth_token_env_ref. 56 - // Deprecated: use AuthToken instead. 57 - AuthTokenEnvRef string `mapstructure:"auth_token_env_ref"` 58 55 } 59 56 60 57 type runtimeEndpoint struct { ··· 178 175 name := strings.TrimSpace(item.Name) 179 176 url := strings.TrimRight(strings.TrimSpace(item.URL), "/") 180 177 token := strings.TrimSpace(item.AuthToken) 181 - tokenRef := strings.TrimSpace(item.AuthTokenEnvRef) 182 - if token == "" && tokenRef != "" { 183 - token = fmt.Sprintf("${%s}", tokenRef) 184 - } 185 - 186 - // expand env refs 187 - token = os.ExpandEnv(token) 188 178 if name == "" || url == "" || token == "" { 189 179 return nil, fmt.Errorf("invalid console.endpoints[%d]: name, url, auth_token are required", i) 190 180 }
+6 -26
cmd/mistermorph/consolecmd/serve_endpoints_test.go
··· 1 1 package consolecmd 2 2 3 3 import ( 4 - "os" 5 4 "testing" 6 5 ) 7 6 8 7 func TestResolveRuntimeEndpoints(t *testing.T) { 9 - os.Setenv("AUTH_TOKEN_A", "alpha") 10 - os.Setenv("AUTH_TOKEN_B", "beta") 11 - 12 8 t.Run("missing_endpoints", func(t *testing.T) { 13 9 _, err := resolveRuntimeEndpoints(nil) 14 10 if err == nil { ··· 25 21 } 26 22 }) 27 23 28 - t.Run("missing_token_env", func(t *testing.T) { 29 - _, err := resolveRuntimeEndpoints([]runtimeEndpointConfigRaw{ 30 - {Name: "a", URL: "http://127.0.0.1:8787", AuthTokenEnvRef: "MISSING"}, 31 - }) 32 - if err == nil { 33 - t.Fatalf("expected error for missing token env") 34 - } 35 - }) 36 - 37 24 t.Run("use raw auth token", func(t *testing.T) { 38 25 out, err := resolveRuntimeEndpoints([]runtimeEndpointConfigRaw{ 39 26 {Name: "a", URL: "http://127.0.0.1:8787", AuthToken: "alpha"}, ··· 47 34 } 48 35 }) 49 36 50 - t.Run("fallback_to_env_ref", func(t *testing.T) { 51 - _, err := resolveRuntimeEndpoints([]runtimeEndpointConfigRaw{ 52 - {Name: "a", URL: "http://127.0.0.1:8787", AuthTokenEnvRef: "AUTH_TOKEN_A"}, 53 - }) 54 - if err != nil { 55 - t.Fatalf("resolveRuntimeEndpoints failed: %v", err) 56 - } 57 - }) 58 - 59 37 t.Run("success", func(t *testing.T) { 38 + // ${ENV_VAR} expansion is handled globally before resolveRuntimeEndpoints 39 + // is called, so tokens arrive pre-expanded. 60 40 out, err := resolveRuntimeEndpoints([]runtimeEndpointConfigRaw{ 61 - {Name: " Telegram ", URL: "http://127.0.0.1:8787/", AuthToken: "${AUTH_TOKEN_A}"}, 62 - {Name: "Slack", URL: "http://127.0.0.1:8788", AuthToken: "${AUTH_TOKEN_B}"}, 41 + {Name: " Telegram ", URL: "http://127.0.0.1:8787/", AuthToken: "alpha"}, 42 + {Name: "Slack", URL: "http://127.0.0.1:8788", AuthToken: "beta"}, 63 43 }) 64 44 if err != nil { 65 45 t.Fatalf("resolveRuntimeEndpoints failed: %v", err) ··· 86 66 87 67 t.Run("duplicate_endpoints", func(t *testing.T) { 88 68 _, err := resolveRuntimeEndpoints([]runtimeEndpointConfigRaw{ 89 - {Name: "Telegram", URL: "http://127.0.0.1:8787", AuthTokenEnvRef: "AUTH_TOKEN_A"}, 90 - {Name: "Telegram", URL: "http://127.0.0.1:8787", AuthTokenEnvRef: "AUTH_TOKEN_A"}, 69 + {Name: "Telegram", URL: "http://127.0.0.1:8787", AuthToken: "alpha"}, 70 + {Name: "Telegram", URL: "http://127.0.0.1:8787", AuthToken: "alpha"}, 91 71 }) 92 72 if err == nil { 93 73 t.Fatalf("expected duplicate endpoint error")
+5 -4
cmd/mistermorph/install_config_wizard.go
··· 286 286 cfg = replaceConfigLine(cfg, ` endpoint: "https://api.openai.com"`, ` endpoint: `+yamlQuotedScalar(setup.Endpoint)) 287 287 cfg = replaceConfigLinePrefix(cfg, " model: ", ` model: `+yamlQuotedScalar(setup.Model)) 288 288 289 + apiKeyComment := " # or set via MISTER_MORPH_LLM_API_KEY" 289 290 switch strings.ToLower(strings.TrimSpace(setup.Provider)) { 290 291 case "cloudflare": 291 - cfg = replaceConfigLine(cfg, ` api_key: "" # or set via MISTER_MORPH_LLM_API_KEY`, ` api_key: "" # or set via MISTER_MORPH_LLM_API_KEY`) 292 + cfg = replaceConfigLinePrefix(cfg, " api_key: ", ` api_key: ""`+apiKeyComment) 292 293 cfg = replaceConfigLine(cfg, ` account_id: ""`, ` account_id: `+yamlQuotedScalar(setup.CloudflareAccount)) 293 - cfg = replaceConfigLine(cfg, ` api_token: ""`, ` api_token: `+yamlQuotedScalar(setup.CloudflareAPIToken)) 294 + cfg = replaceConfigLinePrefix(cfg, " api_token: ", ` api_token: `+yamlQuotedScalar(setup.CloudflareAPIToken)) 294 295 default: 295 - cfg = replaceConfigLine(cfg, ` api_key: "" # or set via MISTER_MORPH_LLM_API_KEY`, ` api_key: `+yamlQuotedScalar(setup.APIKey)+` # or set via MISTER_MORPH_LLM_API_KEY`) 296 + cfg = replaceConfigLinePrefix(cfg, " api_key: ", ` api_key: `+yamlQuotedScalar(setup.APIKey)+apiKeyComment) 296 297 cfg = replaceConfigLine(cfg, ` account_id: ""`, ` account_id: ""`) 297 - cfg = replaceConfigLine(cfg, ` api_token: ""`, ` api_token: ""`) 298 + cfg = replaceConfigLinePrefix(cfg, " api_token: ", ` api_token: ""`) 298 299 } 299 300 300 301 cfg = replaceConfigLine(cfg, ` bot_token: ""`, ` bot_token: `+yamlQuotedScalar(setup.TelegramBotToken))
-2
cmd/mistermorph/registry.go
··· 157 157 "auth_profiles", len(authProfiles), 158 158 ) 159 159 160 - resolver := &secrets.EnvResolver{} 161 160 profileStore := secrets.NewProfileStore(authProfiles) 162 161 authenticatedHTTPConfigured := hasAllowedAuthProfiles(allowProfiles, authProfiles) 163 162 ··· 191 190 Auth: &builtin.URLFetchAuth{ 192 191 AllowProfiles: allowProfiles, 193 192 Profiles: profileStore, 194 - Resolver: resolver, 195 193 }, 196 194 }, 197 195 WebSearch: toolsutil.StaticWebSearchConfig{
+6 -13
cmd/mistermorph/root.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "errors" 6 5 "fmt" 7 6 "log/slog" 8 7 "os" ··· 21 20 "github.com/quailyquaily/mistermorph/cmd/mistermorph/slackcmd" 22 21 "github.com/quailyquaily/mistermorph/cmd/mistermorph/telegramcmd" 23 22 "github.com/quailyquaily/mistermorph/guard" 23 + "github.com/quailyquaily/mistermorph/internal/configutil" 24 24 "github.com/quailyquaily/mistermorph/internal/heartbeatutil" 25 25 "github.com/quailyquaily/mistermorph/internal/llmstats" 26 26 "github.com/quailyquaily/mistermorph/internal/llmutil" ··· 211 211 return 212 212 } 213 213 214 - viper.SetConfigFile(cfgFile) 215 - if err := viper.ReadInConfig(); err != nil { 216 - if !explicit && isConfigNotFoundError(err) { 214 + warnf := func(format string, args ...any) { 215 + _, _ = fmt.Fprintf(os.Stderr, "warn: "+format+"\n", args...) 216 + } 217 + if err := configutil.ReadExpandedConfig(viper.GetViper(), cfgFile, warnf); err != nil { 218 + if !explicit && os.IsNotExist(err) { 217 219 return 218 220 } 219 221 _, _ = fmt.Fprintf(os.Stderr, "Failed to read config: %v\n", err) 220 222 return 221 223 } 222 - 223 224 expandConfiguredDirKey("file_state_dir") 224 225 expandConfiguredDirKey("file_cache_dir") 225 226 } ··· 237 238 } 238 239 } 239 240 return "", false 240 - } 241 - 242 - func isConfigNotFoundError(err error) bool { 243 - if os.IsNotExist(err) { 244 - return true 245 - } 246 - var notFound viper.ConfigFileNotFoundError 247 - return errors.As(err, &notFound) 248 241 } 249 242 250 243 func expandConfiguredDirKey(key string) {
+1 -1
deploy/systemd/README.md
··· 113 113 114 114 Notes: 115 115 - Keep `mister-morph` daemon service running; Console reads tasks from `console.endpoints[].url`. 116 - - Ensure endpoint token env vars referenced by `console.endpoints[].auth_token_env_ref` are set for the console process. 116 + - Ensure endpoint token env vars referenced by `console.endpoints[].auth_token` (via `${ENV_VAR}` syntax) are set for the console process. 117 117 118 118 ## Run mode 119 119
+2 -2
deploy/systemd/mister-morph.service
··· 18 18 # Secrets/config via environment (recommended). 19 19 # Suggested split: 20 20 # - /opt/morph/morph.env: non-secret config (permissions: 0640) 21 - # - /opt/morph/morph.secrets.env: secret values / secret_ref env vars (permissions: 0600) 21 + # - /opt/morph/morph.secrets.env: secret values referenced via ${ENV_VAR} in config (permissions: 0600) 22 22 # 23 23 # Common env vars: 24 24 # - MISTER_MORPH_LLM_API_KEY 25 25 # - MISTER_MORPH_SERVER_AUTH_TOKEN 26 - # - Any auth_profile secret_ref env vars (e.g. JSONBILL_API_KEY) 26 + # - Any auth_profile secret env vars (e.g. JSONBILL_API_KEY) 27 27 EnvironmentFile=-/opt/morph/morph.env 28 28 EnvironmentFile=-/opt/morph/morph.secrets.env 29 29
+1 -1
docs/console.md
··· 123 123 endpoints: 124 124 - name: "Main" 125 125 url: "http://127.0.0.1:8787" 126 - auth_token_env_ref: "MISTER_MORPH_ENDPOINT_MAIN_TOKEN" 126 + auth_token: "${MISTER_MORPH_ENDPOINT_MAIN_TOKEN}" 127 127 ``` 128 128 129 129 4. Open:
+15 -19
docs/feat/feat_20260201_secret_manager.md
··· 13 13 This design significantly reduces the following risks: 14 14 15 15 - **Prompt injection / social-engineering leakage**: the model never sees the raw key, so it cannot leak it even if coerced. 16 - - **Tool call trace leakage**: tool params / traces / debug logs must not carry secrets (only references like `secret_ref`). 16 + - **Tool call trace leakage**: tool params / traces / debug logs must not carry secrets. 17 17 - **Untrusted skill blast radius**: a skill can declare “what it needs” but never holds the secret value. 18 18 - **Accidental copy/commit**: avoids secrets ending up in `SKILL.md`, example configs, or run logs. 19 19 20 20 There are also a few points that need to be strengthened/clarified (recommended TODOs): 21 21 22 - - **`secret_ref` must have authorization boundaries**: if arbitrary user text can make the model choose `secret_ref=ANYTHING`, it can still access unrelated secrets. You need an allowlist / binding relationship (skill ↔ profile/secret_ref ↔ tool). 22 + - **Secrets must have authorization boundaries**: if arbitrary user text can make the model choose any profile, it can still access unrelated secrets. You need an allowlist / binding relationship (skill ↔ profile ↔ tool). 23 23 - **Do not let the LLM assemble full HTTP requests**: headers/body assembly must be controlled by the host (tool implementation or middleware). 24 24 - **Log redaction must cover common header names**: redaction based on `api_key` / `authorization` alone is easy to miss; names like `X-API-Key` must be covered. 25 25 ··· 30 30 This design recommends **profile-based auth** as the only supported path: 31 31 32 32 - Skills/LLM reference **only** a profile id. 33 - - Skills/LLM do **not** specify `secret_ref`. 33 + - Skills/LLM do **not** specify secrets. 34 34 - Skills/LLM do **not** specify injection location/header names. 35 35 36 36 This is necessary to achieve “least exposure” and a small, host-controlled input surface, preventing prompt injection from rewriting injection details or target domains. ··· 53 53 54 54 ### 2.2 Secret Resolver / Credential Provider (Host side) 55 55 56 - Introduce a Secret Resolver (Credential Provider) interface: 56 + Secrets are resolved at config load time via `${ENV_VAR}` expansion in all string config values. 57 57 58 - - Input: `secret_ref` (e.g. `JSONBILL_API_KEY`) 59 - - Output: the plaintext secret, **in-memory only**, for a short lifetime (string/bytes) 60 - 61 - MVP implementation: `EnvResolver` 58 + - `credential.secret` holds the secret value (use `${ENV_VAR}` to reference env vars) 62 59 63 - - Default mapping: `secret_ref` maps directly to an environment variable name (e.g. `JSONBILL_API_KEY`) 64 - - Failure policy: fail-closed (missing => error; do not “let the model guess”) 60 + - Failure policy: fail-closed (empty secret after expansion => error) 65 61 66 62 ### 2.3 Injection Point (HTTP layer only) 67 63 68 64 For tools that need auth, enforce two hard rules: 69 65 70 - 1) **The LLM can only pass `auth_profile`, never `secret_ref`, and never any auth value (header/query/body/subprotocol/etc).** 66 + 1) **The LLM can only pass `auth_profile`, never secrets, and never any auth value (header/query/body/subprotocol/etc).** 71 67 2) **Final auth injection happens inside the tool (or middleware) and must not appear in observations/logs.** 72 68 73 69 In this repo, `url_fetch` (`tools/builtin/url_fetch.go`) is the right place to implement “safe HTTP” because: ··· 96 92 97 93 Future tools (e.g. `websocket_fetch`) may need query params, WebSocket subprotocols, handshake headers, or request signing. Therefore the profile config should be split into: 98 94 99 - - **Credential**: `secret_ref` + kind (api_key/bearer/…), only “where to resolve plaintext” 95 + - **Credential**: `secret` + kind (api_key/bearer/…), the resolved secret value (use `${ENV_VAR}` in config) 100 96 - **Bindings**: tool-specific injection strategy declared per tool (host-controlled) 101 97 102 98 This way: ··· 131 127 - Add `secrets` (or `credentials`) package: define `Resolver` interface + `EnvResolver` implementation (env only, never persisted). 132 128 - Inject the resolver during engine/tool construction (avoid tools calling `os.Getenv` directly). 133 129 - Introduce **profile-based auth (only supported path)**: 134 - - Add `auth_profile` param to `url_fetch`: the LLM/skill can pass only `profile_id`, not injection details or `secret_ref`. 130 + - Add `auth_profile` param to `url_fetch`: the LLM/skill can pass only `profile_id`, not injection details or secrets. 135 131 - Load `auth_profiles` from config (host-defined): 136 - - `credential`: bound `secret_ref` + `kind` (api_key/bearer/…) 132 + - `credential`: `secret` + `kind` (api_key/bearer/…) 137 133 - `allow`: `url_prefixes` + `methods` (plus flags like `follow_redirects`) 138 134 - `bindings`: tool injection rules (e.g. `bindings.url_fetch.inject.location=header` + `inject.name=Authorization` + `inject.format=bearer`) 139 135 - `url_fetch` execution: validate the URL against `allow` first, then resolve/inject the secret according to `bindings.url_fetch`; do not leak injection through redirects (recommended default: redirects disabled). 140 - - Explicitly do not support `auth.secret_ref` (avoid a “bypass API” that skips profile boundaries). 136 + - Explicitly do not support passing secrets directly (avoid a “bypass API” that skips profile boundaries). 141 137 - Binding validation + failure policy: 142 138 - If `bindings.<tool>` is missing: using that `auth_profile` with that tool must error (avoid silently dropping auth). 143 139 - If `inject.location` / fields are unsupported by the tool: error (fail-closed). ··· 166 162 - When allowlisted auth profiles are configured, allow `bash` for local automation, but deny `curl` by default to avoid “bash + curl” carrying authenticated HTTP; use `url_fetch + auth_profile` for HTTP. 167 163 - If curl features are needed, prefer a structured subprocess tool (e.g. `exec`/`curl_fetch`) that takes `profile_id + argv + stdin` and injects secrets host-side with a minimal environment. 168 164 - `auth_profiles` config (recommend in `assets/config/config.example.yaml`): 169 - - `auth_profiles.<id>.credential.secret_ref` 165 + - `auth_profiles.<id>.credential.secret` 170 166 - `auth_profiles.<id>.credential.kind` (api_key/bearer/...) 171 167 - `auth_profiles.<id>.allow.url_prefixes` / `methods` 172 168 - `auth_profiles.<id>.allow.follow_redirects` (default false) ··· 210 206 211 207 - [x] Define `AuthProfile` (`secrets/`): 212 208 - [x] `ID` (map key) 213 - - [x] `Credential`: `kind` + `secret_ref` 209 + - [x] `Credential`: `kind` + `secret` 214 210 - [x] `Allow` (fail-closed): 215 211 - [x] `url_prefixes` (each entry is a full URL prefix; scheme/host/port/path are bound in one rule) 216 212 - [x] `methods` (normalize to upper) ··· 298 294 jsonbill: 299 295 credential: 300 296 kind: api_key 301 - secret_ref: JSONBILL_API_KEY 297 + secret: "${JSONBILL_API_KEY}" 302 298 allow: 303 299 url_prefixes: ["https://api.jsonbill.com/tasks/docs"] 304 300 methods: ["POST"] ··· 343 339 344 340 - **Redirect safety**: Go redirects may carry custom headers to the redirected target; keep redirects disabled by default or use `CheckRedirect` to allow only same-origin and to re-inject auth safely. 345 341 - **`bash` environment inheritance**: even if the LLM never sees the key, a `bash` tool that inherits env can read it via `env`/`printenv`/`/proc/self/environ` and exfiltrate; recommend a minimal env allowlist and Guard hard blocks for risky patterns. 346 - - **LLM provider key (`llm.api_key`)**: this is also a secret (even if not placed into prompts) and can leak through logs/traces; ensure provider auth is never logged, and consider unifying it under `secret_ref`. 342 + - **LLM provider key (`llm.api_key`)**: this is also a secret (even if not placed into prompts) and can leak through logs/traces; ensure provider auth is never logged, and consider unifying it under the `${ENV_VAR}` config expansion. 347 343 - **Secret/profile scope model**: beyond allowlists, define scope boundaries (per-run / per-skill / per-tool / per-domain) to prevent reuse across unrelated actions. 348 344 - **Network boundaries**: proxy usage (`HTTP_PROXY`), custom `Host`, and TLS verification policies all affect “least exposure”; document defaults and disallowed options. 349 345 - **Test coverage**: ensure tests cover sensitive header rejection, auth_profile injection without observation/log leakage, redirect behavior, and response redaction rules.
+2 -2
docs/feat/feat_20260219_httpd_admin_console.md
··· 50 50 endpoints: 51 51 - name: "Main" 52 52 url: "http://127.0.0.1:8787" 53 - auth_token_env_ref: "MISTER_MORPH_ENDPOINT_MAIN_TOKEN" 53 + auth_token: "${MISTER_MORPH_ENDPOINT_MAIN_TOKEN}" 54 54 ``` 55 55 56 56 Required inputs: ··· 60 60 Recommended env vars: 61 61 - `MISTER_MORPH_CONSOLE_PASSWORD` 62 62 - `MISTER_MORPH_CONSOLE_PASSWORD_HASH` 63 - - endpoint token envs referenced by `console.endpoints[*].auth_token_env_ref` 63 + - endpoint token envs referenced by `console.endpoints[*].auth_token` (use `${ENV_VAR}` syntax) 64 64 65 65 ## 4) Auth and Security Model 66 66
+2 -2
docs/security.md
··· 206 206 To avoid this, `mistermorph` supports **profile-based credential injection**: 207 207 208 208 - Skills/LLM only reference a profile id (e.g. `auth_profile: "jsonbill"`). 209 - - The host resolves the real secret value from the environment. `secret_ref` is the environment variable name. 209 + - The host resolves the real secret value at config load time. Use `${ENV_VAR}` syntax in `credential.secret` to reference environment variables. 210 210 - The tool injects the credential into the actual HTTP request (e.g. `Authorization: Bearer …`) without logging it. 211 211 212 212 ### Configure profiles ··· 221 221 jsonbill: 222 222 credential: 223 223 kind: api_key 224 - secret_ref: JSONBILL_API_KEY 224 + secret: "${JSONBILL_API_KEY}" 225 225 allow: 226 226 url_prefixes: ["https://api.jsonbill.com/tasks"] 227 227 methods: ["POST", "GET"]
-2
integration/registry.go
··· 53 53 "auth_profiles", len(authProfiles), 54 54 ) 55 55 56 - resolver := &secrets.EnvResolver{} 57 56 profileStore := secrets.NewProfileStore(authProfiles) 58 57 authenticatedHTTPConfigured := hasAllowedAuthProfiles(allowProfiles, authProfiles) 59 58 ··· 87 86 Auth: &builtin.URLFetchAuth{ 88 87 AllowProfiles: allowProfiles, 89 88 Profiles: profileStore, 90 - Resolver: resolver, 91 89 }, 92 90 }, 93 91 WebSearch: toolsutil.StaticWebSearchConfig{
+56
internal/configutil/expand.go
··· 1 + package configutil 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "regexp" 7 + "strings" 8 + 9 + "github.com/spf13/viper" 10 + ) 11 + 12 + // envVarRe matches only the ${NAME} form (not bare $NAME). 13 + // This avoids corrupting values like bcrypt hashes ($2a$10$...) or 14 + // regex patterns that contain literal dollar signs. 15 + var envVarRe = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) 16 + 17 + // expandStrictEnv replaces only ${VAR} references with their environment 18 + // values. Bare $VAR references are left untouched. 19 + // Returns the expanded string and a list of referenced-but-unset variable names. 20 + func expandStrictEnv(s string) (string, []string) { 21 + var missing []string 22 + result := envVarRe.ReplaceAllStringFunc(s, func(match string) string { 23 + name := envVarRe.FindStringSubmatch(match)[1] 24 + val, ok := os.LookupEnv(name) 25 + if !ok { 26 + missing = append(missing, name) 27 + return "" 28 + } 29 + return val 30 + }) 31 + return result, missing 32 + } 33 + 34 + // ReadExpandedConfig reads a config file, expands only ${ENV_VAR} 35 + // references in the raw text, then feeds the result into the provided 36 + // viper instance. 37 + // 38 + // Unset environment variables are replaced with empty strings and 39 + // reported via the optional warn callback. Pass nil to suppress warnings. 40 + func ReadExpandedConfig(v *viper.Viper, path string, warn func(format string, args ...any)) error { 41 + raw, err := os.ReadFile(path) 42 + if err != nil { 43 + return err 44 + } 45 + expanded, missing := expandStrictEnv(string(raw)) 46 + if len(missing) > 0 && warn != nil { 47 + warn("config %s: unset environment variable(s) replaced with empty string: %s", 48 + filepath.Base(path), strings.Join(missing, ", ")) 49 + } 50 + ext := strings.TrimPrefix(filepath.Ext(path), ".") 51 + if ext == "" { 52 + ext = "yaml" 53 + } 54 + v.SetConfigType(ext) 55 + return v.ReadConfig(strings.NewReader(expanded)) 56 + }
+162
internal/configutil/expand_test.go
··· 1 + package configutil 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/spf13/viper" 11 + ) 12 + 13 + func TestReadExpandedConfig(t *testing.T) { 14 + t.Setenv("TEST_SECRET", "hunter2") 15 + t.Setenv("TEST_TOKEN", "tok-abc") 16 + 17 + yaml := ` 18 + plain: hello 19 + with_env: "${TEST_SECRET}" 20 + nested: 21 + key: "${TEST_TOKEN}" 22 + no_dollar: world 23 + items: 24 + - name: a 25 + token: "${TEST_SECRET}" 26 + port: 8080 27 + ` 28 + path := filepath.Join(t.TempDir(), "config.yaml") 29 + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { 30 + t.Fatal(err) 31 + } 32 + 33 + v := viper.New() 34 + if err := ReadExpandedConfig(v, path, nil); err != nil { 35 + t.Fatalf("ReadExpandedConfig() error = %v", err) 36 + } 37 + 38 + tests := []struct { 39 + key string 40 + want string 41 + }{ 42 + {"plain", "hello"}, 43 + {"with_env", "hunter2"}, 44 + {"nested.key", "tok-abc"}, 45 + {"no_dollar", "world"}, 46 + } 47 + for _, tt := range tests { 48 + if got := v.GetString(tt.key); got != tt.want { 49 + t.Errorf("%s = %q, want %q", tt.key, got, tt.want) 50 + } 51 + } 52 + 53 + if got := v.GetInt("port"); got != 8080 { 54 + t.Fatalf("port = %d, want 8080", got) 55 + } 56 + 57 + items := v.Get("items") 58 + slice, ok := items.([]any) 59 + if !ok || len(slice) == 0 { 60 + t.Fatalf("expected non-empty slice, got %T %v", items, items) 61 + } 62 + m, ok := slice[0].(map[string]any) 63 + if !ok { 64 + t.Fatalf("expected map, got %T", slice[0]) 65 + } 66 + if m["token"] != "hunter2" { 67 + t.Fatalf("items[0].token = %q, want hunter2", m["token"]) 68 + } 69 + } 70 + 71 + func TestReadExpandedConfig_PreservesLiteralDollar(t *testing.T) { 72 + yaml := ` 73 + regex_pattern: "password=(.+)$" 74 + bare_var: "$HOME_SHOULD_NOT_EXPAND" 75 + bcrypt_hash: "$2a$10$abcdefghijklmnopqrstu" 76 + ` 77 + path := filepath.Join(t.TempDir(), "config.yaml") 78 + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { 79 + t.Fatal(err) 80 + } 81 + 82 + v := viper.New() 83 + if err := ReadExpandedConfig(v, path, nil); err != nil { 84 + t.Fatalf("ReadExpandedConfig() error = %v", err) 85 + } 86 + 87 + if got := v.GetString("regex_pattern"); got != "password=(.+)$" { 88 + t.Errorf("regex_pattern = %q, want %q", got, "password=(.+)$") 89 + } 90 + if got := v.GetString("bare_var"); got != "$HOME_SHOULD_NOT_EXPAND" { 91 + t.Errorf("bare_var = %q, want %q (bare $VAR must not be expanded)", got, "$HOME_SHOULD_NOT_EXPAND") 92 + } 93 + if got := v.GetString("bcrypt_hash"); got != "$2a$10$abcdefghijklmnopqrstu" { 94 + t.Errorf("bcrypt_hash = %q, want %q (bcrypt hashes must not be mangled)", got, "$2a$10$abcdefghijklmnopqrstu") 95 + } 96 + } 97 + 98 + func TestReadExpandedConfig_UnsetVarWarns(t *testing.T) { 99 + yaml := ` 100 + key: "${UNSET_VAR_XYZ_NEVER_SET}" 101 + ` 102 + path := filepath.Join(t.TempDir(), "config.yaml") 103 + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { 104 + t.Fatal(err) 105 + } 106 + 107 + var warnings []string 108 + warnf := func(format string, args ...any) { 109 + warnings = append(warnings, fmt.Sprintf(format, args...)) 110 + } 111 + 112 + v := viper.New() 113 + if err := ReadExpandedConfig(v, path, warnf); err != nil { 114 + t.Fatalf("ReadExpandedConfig() unexpected error = %v", err) 115 + } 116 + if len(warnings) == 0 { 117 + t.Fatal("expected warning for unset env var reference") 118 + } 119 + if !strings.Contains(warnings[0], "UNSET_VAR_XYZ_NEVER_SET") { 120 + t.Fatalf("warning should mention the unset var name, got: %v", warnings[0]) 121 + } 122 + if got := v.GetString("key"); got != "" { 123 + t.Errorf("key = %q, want empty (unset var should expand to empty)", got) 124 + } 125 + } 126 + 127 + func TestReadExpandedConfig_FileNotFound(t *testing.T) { 128 + v := viper.New() 129 + err := ReadExpandedConfig(v, "/tmp/nonexistent_config_xyz.yaml", nil) 130 + if err == nil { 131 + t.Fatal("expected error for missing file") 132 + } 133 + } 134 + 135 + func TestExpandStrictEnv(t *testing.T) { 136 + t.Setenv("MY_VAR", "hello") 137 + 138 + tests := []struct { 139 + name string 140 + input string 141 + want string 142 + wantMissing []string 143 + }{ 144 + {"braced var", "${MY_VAR}", "hello", nil}, 145 + {"bare var untouched", "$MY_VAR stays", "$MY_VAR stays", nil}, 146 + {"bcrypt hash", "$2a$10$xyz", "$2a$10$xyz", nil}, 147 + {"missing var", "${NO_SUCH_VAR}", "", []string{"NO_SUCH_VAR"}}, 148 + {"mixed", "${MY_VAR} and $BARE", "hello and $BARE", nil}, 149 + {"empty braces", "${}", "${}", nil}, 150 + } 151 + for _, tt := range tests { 152 + t.Run(tt.name, func(t *testing.T) { 153 + got, missing := expandStrictEnv(tt.input) 154 + if got != tt.want { 155 + t.Errorf("expandStrictEnv(%q) = %q, want %q", tt.input, got, tt.want) 156 + } 157 + if len(missing) != len(tt.wantMissing) { 158 + t.Errorf("missing = %v, want %v", missing, tt.wantMissing) 159 + } 160 + }) 161 + } 162 + }
+24 -40
internal/llmutil/llmutil.go
··· 20 20 Provider string `config:"llm.provider"` 21 21 Endpoint string `config:"llm.endpoint"` 22 22 APIKey string `config:"llm.api_key"` 23 - APIKeyRef string `config:"llm.api_key_ref"` 24 23 Model string `config:"llm.model"` 25 24 AzureDeployment string `config:"llm.azure.deployment"` 26 25 RequestTimeoutRaw string `config:"llm.request_timeout"` ··· 31 30 Profiles map[string]ProfileConfig 32 31 Routes RoutesConfig 33 32 34 - BedrockAWSKey string `config:"llm.bedrock.aws_key"` 35 - BedrockAWSKeyRef string `config:"llm.bedrock.aws_key_ref"` 36 - BedrockAWSSecret string `config:"llm.bedrock.aws_secret"` 37 - BedrockAWSSecretRef string `config:"llm.bedrock.aws_secret_ref"` 38 - BedrockAWSRegion string `config:"llm.bedrock.region"` 39 - BedrockModelARN string `config:"llm.bedrock.model_arn"` 40 - CloudflareAccountID string `config:"llm.cloudflare.account_id"` 41 - CloudflareAPIToken string `config:"llm.cloudflare.api_token"` 42 - CloudflareAPITokenRef string `config:"llm.cloudflare.api_token_ref"` 33 + BedrockAWSKey string `config:"llm.bedrock.aws_key"` 34 + BedrockAWSSecret string `config:"llm.bedrock.aws_secret"` 35 + BedrockAWSRegion string `config:"llm.bedrock.region"` 36 + BedrockModelARN string `config:"llm.bedrock.model_arn"` 37 + CloudflareAccountID string `config:"llm.cloudflare.account_id"` 38 + CloudflareAPIToken string `config:"llm.cloudflare.api_token"` 43 39 } 44 40 45 41 func RuntimeValuesFromReader(r ConfigReader) RuntimeValues { ··· 47 43 return RuntimeValues{} 48 44 } 49 45 return RuntimeValues{ 50 - Provider: strings.TrimSpace(r.GetString("llm.provider")), 51 - Endpoint: strings.TrimSpace(r.GetString("llm.endpoint")), 52 - APIKey: strings.TrimSpace(r.GetString("llm.api_key")), 53 - APIKeyRef: strings.TrimSpace(r.GetString("llm.api_key_ref")), 54 - Model: strings.TrimSpace(r.GetString("llm.model")), 55 - AzureDeployment: strings.TrimSpace(r.GetString("llm.azure.deployment")), 56 - RequestTimeoutRaw: strings.TrimSpace(r.GetString("llm.request_timeout")), 57 - ToolsEmulationMode: strings.TrimSpace(r.GetString("llm.tools_emulation_mode")), 58 - TemperatureRaw: strings.TrimSpace(r.GetString("llm.temperature")), 59 - ReasoningEffortRaw: strings.TrimSpace(r.GetString("llm.reasoning_effort")), 60 - ReasoningBudgetRaw: strings.TrimSpace(r.GetString("llm.reasoning_budget_tokens")), 61 - Profiles: loadLLMProfilesFromReader(r), 62 - Routes: loadLLMRoutesFromReader(r), 63 - BedrockAWSKey: firstNonEmpty(r.GetString("llm.bedrock.aws_key"), r.GetString("llm.aws.key")), 64 - BedrockAWSKeyRef: strings.TrimSpace(r.GetString("llm.bedrock.aws_key_ref")), 65 - BedrockAWSSecret: firstNonEmpty(r.GetString("llm.bedrock.aws_secret"), r.GetString("llm.aws.secret")), 66 - BedrockAWSSecretRef: strings.TrimSpace(r.GetString("llm.bedrock.aws_secret_ref")), 67 - BedrockAWSRegion: firstNonEmpty(r.GetString("llm.bedrock.region"), r.GetString("llm.aws.region")), 68 - BedrockModelARN: firstNonEmpty(r.GetString("llm.bedrock.model_arn"), r.GetString("llm.aws.bedrock_model_arn")), 69 - CloudflareAccountID: firstNonEmpty(r.GetString("llm.cloudflare.account_id")), 70 - CloudflareAPIToken: firstNonEmpty(r.GetString("llm.cloudflare.api_token")), 71 - CloudflareAPITokenRef: strings.TrimSpace(r.GetString("llm.cloudflare.api_token_ref")), 46 + Provider: strings.TrimSpace(r.GetString("llm.provider")), 47 + Endpoint: strings.TrimSpace(r.GetString("llm.endpoint")), 48 + APIKey: strings.TrimSpace(r.GetString("llm.api_key")), 49 + Model: strings.TrimSpace(r.GetString("llm.model")), 50 + AzureDeployment: strings.TrimSpace(r.GetString("llm.azure.deployment")), 51 + RequestTimeoutRaw: strings.TrimSpace(r.GetString("llm.request_timeout")), 52 + ToolsEmulationMode: strings.TrimSpace(r.GetString("llm.tools_emulation_mode")), 53 + TemperatureRaw: strings.TrimSpace(r.GetString("llm.temperature")), 54 + ReasoningEffortRaw: strings.TrimSpace(r.GetString("llm.reasoning_effort")), 55 + ReasoningBudgetRaw: strings.TrimSpace(r.GetString("llm.reasoning_budget_tokens")), 56 + Profiles: loadLLMProfilesFromReader(r), 57 + Routes: loadLLMRoutesFromReader(r), 58 + BedrockAWSKey: firstNonEmpty(r.GetString("llm.bedrock.aws_key"), r.GetString("llm.aws.key")), 59 + BedrockAWSSecret: firstNonEmpty(r.GetString("llm.bedrock.aws_secret"), r.GetString("llm.aws.secret")), 60 + BedrockAWSRegion: firstNonEmpty(r.GetString("llm.bedrock.region"), r.GetString("llm.aws.region")), 61 + BedrockModelARN: firstNonEmpty(r.GetString("llm.bedrock.model_arn"), r.GetString("llm.aws.bedrock_model_arn")), 62 + CloudflareAccountID: firstNonEmpty(r.GetString("llm.cloudflare.account_id")), 63 + CloudflareAPIToken: firstNonEmpty(r.GetString("llm.cloudflare.api_token")), 72 64 } 73 65 } 74 66 ··· 232 224 return 0, fmt.Errorf("invalid %s %q", path, raw) 233 225 } 234 226 return value, nil 235 - } 236 - 237 - func resolveRefs(values RuntimeValues) (RuntimeValues, error) { 238 - out := values 239 - if err := resolveStructRefs(&out); err != nil { 240 - return RuntimeValues{}, err 241 - } 242 - return out, nil 243 227 } 244 228 245 229 func normalizeProvider(provider string) string {
+17 -83
internal/llmutil/llmutil_test.go
··· 229 229 } 230 230 } 231 231 232 - func TestResolveRoute_TopLevelAPIKeyRef(t *testing.T) { 233 - t.Setenv("OPENAI_API_KEY", "env-openai-key") 234 - values := RuntimeValues{ 235 - Provider: "openai", 236 - APIKey: "plain-key", 237 - APIKeyRef: "OPENAI_API_KEY", 238 - Model: "gpt-5.2", 239 - } 240 - resolved, err := ResolveRoute(values, RoutePurposeMainLoop) 241 - if err != nil { 242 - t.Fatalf("ResolveRoute() error = %v", err) 243 - } 244 - if resolved.ClientConfig.APIKey != "env-openai-key" { 245 - t.Fatalf("api key = %q, want env-openai-key", resolved.ClientConfig.APIKey) 246 - } 247 - } 248 - 249 - func TestResolveRoute_ProfileAPIKeyRef(t *testing.T) { 250 - t.Setenv("XAI_API_KEY", "env-xai-key") 232 + func TestResolveRoute_ProfileAPIKeyOverride(t *testing.T) { 251 233 values := RuntimeValues{ 252 234 Provider: "openai", 253 - APIKey: "plain-key", 235 + APIKey: "base-key", 254 236 Model: "gpt-5.2", 255 237 Profiles: map[string]ProfileConfig{ 256 238 "reasoning": { 257 - Provider: "xai", 258 - Model: "grok-4.1-fast-reasoning", 259 - APIKeyRef: "XAI_API_KEY", 239 + Provider: "xai", 240 + Model: "grok-4.1-fast-reasoning", 241 + APIKey: "xai-key", 260 242 }, 261 243 }, 262 244 Routes: RoutesConfig{ ··· 272 254 if resolved.ClientConfig.Provider != "xai" { 273 255 t.Fatalf("provider = %q, want xai", resolved.ClientConfig.Provider) 274 256 } 275 - if resolved.ClientConfig.APIKey != "env-xai-key" { 276 - t.Fatalf("api key = %q, want env-xai-key", resolved.ClientConfig.APIKey) 257 + if resolved.ClientConfig.APIKey != "xai-key" { 258 + t.Fatalf("api key = %q, want xai-key", resolved.ClientConfig.APIKey) 277 259 } 278 260 } 279 261 280 - func TestResolveRoute_CloudflareAPITokenRef(t *testing.T) { 281 - t.Setenv("CF_API_TOKEN", "env-cf-token") 262 + func TestResolveRoute_CloudflareAPIToken(t *testing.T) { 282 263 values := RuntimeValues{ 283 - Provider: "cloudflare", 284 - Model: "@cf/meta/llama-4", 285 - CloudflareAccountID: "acc-id", 286 - CloudflareAPITokenRef: "CF_API_TOKEN", 264 + Provider: "cloudflare", 265 + Model: "@cf/meta/llama-4", 266 + CloudflareAccountID: "acc-id", 267 + CloudflareAPIToken: "cf-token", 287 268 } 288 269 resolved, err := ResolveRoute(values, RoutePurposeMainLoop) 289 270 if err != nil { ··· 292 273 if resolved.ClientConfig.Provider != "cloudflare" { 293 274 t.Fatalf("provider = %q, want cloudflare", resolved.ClientConfig.Provider) 294 275 } 295 - if resolved.ClientConfig.APIKey != "env-cf-token" { 296 - t.Fatalf("api key = %q, want env-cf-token", resolved.ClientConfig.APIKey) 297 - } 298 - } 299 - 300 - func TestResolveRoute_ProfilePlainAPIKeyOverridesInheritedRef(t *testing.T) { 301 - t.Setenv("OPENAI_API_KEY", "env-openai-key") 302 - values := RuntimeValues{ 303 - Provider: "openai", 304 - APIKeyRef: "OPENAI_API_KEY", 305 - Model: "gpt-5.2", 306 - Profiles: map[string]ProfileConfig{ 307 - "reasoning": { 308 - Provider: "xai", 309 - Model: "grok-4.1-fast-reasoning", 310 - APIKey: "plain-xai-key", 311 - }, 312 - }, 313 - Routes: RoutesConfig{ 314 - PurposeRoutes: PurposeRoutes{ 315 - PlanCreate: "reasoning", 316 - }, 317 - }, 318 - } 319 - resolved, err := ResolveRoute(values, RoutePurposePlanCreate) 320 - if err != nil { 321 - t.Fatalf("ResolveRoute() error = %v", err) 322 - } 323 - if resolved.ClientConfig.APIKey != "plain-xai-key" { 324 - t.Fatalf("api key = %q, want plain-xai-key", resolved.ClientConfig.APIKey) 325 - } 326 - } 327 - 328 - func TestResolveRoute_APIKeyRefMissing(t *testing.T) { 329 - values := RuntimeValues{ 330 - Provider: "openai", 331 - APIKeyRef: "MISSING_OPENAI_API_KEY", 332 - Model: "gpt-5.2", 333 - } 334 - _, err := ResolveRoute(values, RoutePurposeMainLoop) 335 - if err == nil { 336 - t.Fatalf("expected missing env error") 337 - } 338 - if !strings.Contains(err.Error(), "llm.api_key_ref") { 339 - t.Fatalf("unexpected error: %v", err) 276 + if resolved.ClientConfig.APIKey != "cf-token" { 277 + t.Fatalf("api key = %q, want cf-token", resolved.ClientConfig.APIKey) 340 278 } 341 279 } 342 280 ··· 364 302 v.Set("llm.provider", "openai") 365 303 v.Set("llm.endpoint", "https://api.openai.com") 366 304 v.Set("llm.api_key", "base-key") 367 - v.Set("llm.api_key_ref", "OPENAI_API_KEY") 368 305 v.Set("llm.model", "gpt-5.2") 369 306 v.Set("llm.request_timeout", "90s") 370 307 v.Set("llm.profiles", map[string]any{ ··· 375 312 "reasoning": map[string]any{ 376 313 "provider": "xai", 377 314 "model": "grok-4.1-fast-reasoning", 378 - "api_key_ref": "XAI_API_KEY", 315 + "api_key": "xai-key", 379 316 "reasoning_effort": "high", 380 317 }, 381 318 }) ··· 393 330 if values.Profiles["reasoning"].ReasoningEffortRaw != "high" { 394 331 t.Fatalf("reasoning effort = %q, want high", values.Profiles["reasoning"].ReasoningEffortRaw) 395 332 } 396 - if values.APIKeyRef != "OPENAI_API_KEY" { 397 - t.Fatalf("api key ref = %q, want OPENAI_API_KEY", values.APIKeyRef) 398 - } 399 - if values.Profiles["reasoning"].APIKeyRef != "XAI_API_KEY" { 400 - t.Fatalf("reasoning api key ref = %q, want XAI_API_KEY", values.Profiles["reasoning"].APIKeyRef) 333 + if values.Profiles["reasoning"].APIKey != "xai-key" { 334 + t.Fatalf("reasoning api key = %q, want xai-key", values.Profiles["reasoning"].APIKey) 401 335 } 402 336 if values.Routes.Addressing != "cheap" { 403 337 t.Fatalf("addressing route = %q, want cheap", values.Routes.Addressing)
-93
internal/llmutil/ref.go
··· 1 - package llmutil 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - "reflect" 7 - "strings" 8 - "unicode" 9 - ) 10 - 11 - func resolveStructRefs(target any) error { 12 - rv := reflect.ValueOf(target) 13 - if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() { 14 - return fmt.Errorf("target must be a non-nil pointer") 15 - } 16 - if rv.Elem().Kind() != reflect.Struct { 17 - return fmt.Errorf("target must point to a struct") 18 - } 19 - return resolveStructRefValues(rv.Elem()) 20 - } 21 - 22 - func resolveStructRefValues(v reflect.Value) error { 23 - t := v.Type() 24 - for i := 0; i < t.NumField(); i++ { 25 - field := t.Field(i) 26 - fieldValue := v.Field(i) 27 - if !fieldValue.CanSet() { 28 - continue 29 - } 30 - switch fieldValue.Kind() { 31 - case reflect.Struct: 32 - if err := resolveStructRefValues(fieldValue); err != nil { 33 - return err 34 - } 35 - case reflect.String: 36 - if !strings.HasSuffix(field.Name, "Ref") { 37 - continue 38 - } 39 - baseName := strings.TrimSuffix(field.Name, "Ref") 40 - baseValue := v.FieldByName(baseName) 41 - if !baseValue.IsValid() || !baseValue.CanSet() || baseValue.Kind() != reflect.String { 42 - continue 43 - } 44 - resolved, err := envRefOrValue(fieldValue.String(), baseValue.String()) 45 - if err != nil { 46 - return fmt.Errorf("%s: %w", configPathForField(field), err) 47 - } 48 - baseValue.SetString(resolved) 49 - } 50 - } 51 - return nil 52 - } 53 - 54 - func envRefOrValue(envRef, value string) (string, error) { 55 - envRef = strings.TrimSpace(envRef) 56 - if envRef == "" { 57 - return strings.TrimSpace(value), nil 58 - } 59 - val, ok := os.LookupEnv(envRef) 60 - if !ok { 61 - return "", fmt.Errorf("env %q is not set", envRef) 62 - } 63 - val = strings.TrimSpace(val) 64 - if val == "" { 65 - return "", fmt.Errorf("env %q is empty", envRef) 66 - } 67 - return val, nil 68 - } 69 - 70 - func configPathForField(field reflect.StructField) string { 71 - if path := strings.TrimSpace(field.Tag.Get("config")); path != "" { 72 - return path 73 - } 74 - if path := strings.TrimSpace(field.Tag.Get("mapstructure")); path != "" && path != ",squash" { 75 - return path 76 - } 77 - return toSnakeCase(field.Name) 78 - } 79 - 80 - func toSnakeCase(s string) string { 81 - var b strings.Builder 82 - for i, r := range s { 83 - if unicode.IsUpper(r) { 84 - if i > 0 { 85 - b.WriteByte('_') 86 - } 87 - b.WriteRune(unicode.ToLower(r)) 88 - continue 89 - } 90 - b.WriteRune(r) 91 - } 92 - return b.String() 93 - }
-47
internal/llmutil/ref_test.go
··· 1 - package llmutil 2 - 3 - import ( 4 - "strings" 5 - "testing" 6 - ) 7 - 8 - func TestResolveStructRefs_RecursivePairing(t *testing.T) { 9 - t.Setenv("TEST_CHILD_API_KEY", "env-child-key") 10 - 11 - type child struct { 12 - APIKey string `config:"llm.child.api_key"` 13 - APIKeyRef string `config:"llm.child.api_key_ref"` 14 - } 15 - type sample struct { 16 - Child child 17 - } 18 - 19 - v := sample{ 20 - Child: child{ 21 - APIKey: "plain-child-key", 22 - APIKeyRef: "TEST_CHILD_API_KEY", 23 - }, 24 - } 25 - if err := resolveStructRefs(&v); err != nil { 26 - t.Fatalf("resolveStructRefs() error = %v", err) 27 - } 28 - if v.Child.APIKey != "env-child-key" { 29 - t.Fatalf("api key = %q, want env-child-key", v.Child.APIKey) 30 - } 31 - } 32 - 33 - func TestResolveStructRefs_MissingEnvUsesConfigPath(t *testing.T) { 34 - type sample struct { 35 - APIKey string `config:"llm.api_key"` 36 - APIKeyRef string `config:"llm.api_key_ref"` 37 - } 38 - 39 - v := sample{APIKeyRef: "MISSING_LLM_API_KEY"} 40 - err := resolveStructRefs(&v) 41 - if err == nil { 42 - t.Fatalf("expected missing env error") 43 - } 44 - if got := err.Error(); !strings.HasPrefix(got, "llm.api_key_ref") { 45 - t.Fatalf("unexpected error: %v", err) 46 - } 47 - }
+10 -35
internal/llmutil/routes.go
··· 21 21 Provider string `mapstructure:"provider"` 22 22 Endpoint string `mapstructure:"endpoint"` 23 23 APIKey string `mapstructure:"api_key"` 24 - APIKeyRef string `mapstructure:"api_key_ref"` 25 24 Model string `mapstructure:"model"` 26 25 RequestTimeoutRaw string `mapstructure:"request_timeout"` 27 26 ToolsEmulationMode string `mapstructure:"tools_emulation_mode"` ··· 32 31 Deployment string `mapstructure:"deployment"` 33 32 } `mapstructure:"azure"` 34 33 Bedrock struct { 35 - AWSKey string `mapstructure:"aws_key"` 36 - AWSKeyRef string `mapstructure:"aws_key_ref"` 37 - AWSSecret string `mapstructure:"aws_secret"` 38 - AWSSecretRef string `mapstructure:"aws_secret_ref"` 39 - Region string `mapstructure:"region"` 40 - ModelARN string `mapstructure:"model_arn"` 34 + AWSKey string `mapstructure:"aws_key"` 35 + AWSSecret string `mapstructure:"aws_secret"` 36 + Region string `mapstructure:"region"` 37 + ModelARN string `mapstructure:"model_arn"` 41 38 } `mapstructure:"bedrock"` 42 39 Cloudflare struct { 43 - AccountID string `mapstructure:"account_id"` 44 - APIToken string `mapstructure:"api_token"` 45 - APITokenRef string `mapstructure:"api_token_ref"` 40 + AccountID string `mapstructure:"account_id"` 41 + APIToken string `mapstructure:"api_token"` 46 42 } `mapstructure:"cloudflare"` 47 43 } 48 44 ··· 86 82 return ResolvedRoute{}, fmt.Errorf("llm route %s targets missing profile %q", purpose, profileName) 87 83 } 88 84 resolvedValues = applyProfileOverride(resolvedValues, override) 89 - } 90 - resolvedValues, err := resolveRefs(resolvedValues) 91 - if err != nil { 92 - return ResolvedRoute{}, err 93 85 } 94 86 95 87 requestTimeout, err := requestTimeoutFromValue(resolvedValues.RequestTimeoutRaw, "llm.request_timeout") ··· 157 149 cfg.Provider = strings.TrimSpace(cfg.Provider) 158 150 cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) 159 151 cfg.APIKey = strings.TrimSpace(cfg.APIKey) 160 - cfg.APIKeyRef = strings.TrimSpace(cfg.APIKeyRef) 161 152 cfg.Model = strings.TrimSpace(cfg.Model) 162 153 cfg.RequestTimeoutRaw = strings.TrimSpace(cfg.RequestTimeoutRaw) 163 154 cfg.ToolsEmulationMode = strings.TrimSpace(cfg.ToolsEmulationMode) ··· 166 157 cfg.ReasoningBudgetRaw = strings.TrimSpace(cfg.ReasoningBudgetRaw) 167 158 cfg.Azure.Deployment = strings.TrimSpace(cfg.Azure.Deployment) 168 159 cfg.Bedrock.AWSKey = strings.TrimSpace(cfg.Bedrock.AWSKey) 169 - cfg.Bedrock.AWSKeyRef = strings.TrimSpace(cfg.Bedrock.AWSKeyRef) 170 160 cfg.Bedrock.AWSSecret = strings.TrimSpace(cfg.Bedrock.AWSSecret) 171 - cfg.Bedrock.AWSSecretRef = strings.TrimSpace(cfg.Bedrock.AWSSecretRef) 172 161 cfg.Bedrock.Region = strings.TrimSpace(cfg.Bedrock.Region) 173 162 cfg.Bedrock.ModelARN = strings.TrimSpace(cfg.Bedrock.ModelARN) 174 163 cfg.Cloudflare.AccountID = strings.TrimSpace(cfg.Cloudflare.AccountID) 175 164 cfg.Cloudflare.APIToken = strings.TrimSpace(cfg.Cloudflare.APIToken) 176 - cfg.Cloudflare.APITokenRef = strings.TrimSpace(cfg.Cloudflare.APITokenRef) 177 165 return cfg 178 166 } 179 167 ··· 236 224 out := cloneRuntimeValuesForRoute(base) 237 225 applyStringOverride(&out.Provider, override.Provider) 238 226 applyStringOverride(&out.Endpoint, override.Endpoint) 239 - applyStringOrRefOverride(&out.APIKey, &out.APIKeyRef, override.APIKey, override.APIKeyRef) 227 + applyStringOverride(&out.APIKey, override.APIKey) 240 228 applyStringOverride(&out.Model, override.Model) 241 229 applyStringOverride(&out.RequestTimeoutRaw, override.RequestTimeoutRaw) 242 230 applyStringOverride(&out.ToolsEmulationMode, override.ToolsEmulationMode) ··· 244 232 applyStringOverride(&out.ReasoningEffortRaw, override.ReasoningEffortRaw) 245 233 applyStringOverride(&out.ReasoningBudgetRaw, override.ReasoningBudgetRaw) 246 234 applyStringOverride(&out.AzureDeployment, override.Azure.Deployment) 247 - applyStringOrRefOverride(&out.BedrockAWSKey, &out.BedrockAWSKeyRef, override.Bedrock.AWSKey, override.Bedrock.AWSKeyRef) 248 - applyStringOrRefOverride(&out.BedrockAWSSecret, &out.BedrockAWSSecretRef, override.Bedrock.AWSSecret, override.Bedrock.AWSSecretRef) 235 + applyStringOverride(&out.BedrockAWSKey, override.Bedrock.AWSKey) 236 + applyStringOverride(&out.BedrockAWSSecret, override.Bedrock.AWSSecret) 249 237 applyStringOverride(&out.BedrockAWSRegion, override.Bedrock.Region) 250 238 applyStringOverride(&out.BedrockModelARN, override.Bedrock.ModelARN) 251 239 applyStringOverride(&out.CloudflareAccountID, override.Cloudflare.AccountID) 252 - applyStringOrRefOverride(&out.CloudflareAPIToken, &out.CloudflareAPITokenRef, override.Cloudflare.APIToken, override.Cloudflare.APITokenRef) 240 + applyStringOverride(&out.CloudflareAPIToken, override.Cloudflare.APIToken) 253 241 return out 254 242 } 255 243 ··· 262 250 } 263 251 } 264 252 265 - func applyStringOrRefOverride(valueDst, refDst *string, value, ref string) { 266 - if valueDst == nil || refDst == nil { 267 - return 268 - } 269 - if value = strings.TrimSpace(value); value != "" { 270 - *valueDst = value 271 - *refDst = "" 272 - } 273 - if ref = strings.TrimSpace(ref); ref != "" { 274 - *valueDst = "" 275 - *refDst = ref 276 - } 277 - }
+11 -1
internal/mcphost/config.go
··· 3 3 import ( 4 4 "fmt" 5 5 "os" 6 + "regexp" 6 7 "strings" 7 8 8 9 "github.com/spf13/viper" 9 10 ) 11 + 12 + var envVarRe = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) 10 13 11 14 type ServerConfig struct { 12 15 Name string ··· 43 46 } 44 47 45 48 // ExpandedHeaders returns headers with ${ENV_VAR} references expanded. 49 + // Only the ${NAME} form is expanded; bare $NAME is left untouched. 46 50 func (c *ServerConfig) ExpandedHeaders() map[string]string { 47 51 if len(c.Headers) == 0 { 48 52 return nil 49 53 } 50 54 out := make(map[string]string, len(c.Headers)) 51 55 for k, v := range c.Headers { 52 - out[k] = os.ExpandEnv(v) 56 + out[k] = envVarRe.ReplaceAllStringFunc(v, func(match string) string { 57 + name := envVarRe.FindStringSubmatch(match)[1] 58 + if val, ok := os.LookupEnv(name); ok { 59 + return val 60 + } 61 + return "" 62 + }) 53 63 } 54 64 return out 55 65 }
-37
secrets/resolver.go
··· 1 - package secrets 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "os" 7 - "strings" 8 - ) 9 - 10 - type Resolver interface { 11 - Resolve(ctx context.Context, secretRef string) (string, error) 12 - } 13 - 14 - // EnvResolver resolves secret_ref values directly from environment variables. 15 - // 16 - // The MVP behavior is fail-closed: 17 - // - missing/unset env var => error 18 - // - empty value => error 19 - type EnvResolver struct{} 20 - 21 - func (r *EnvResolver) Resolve(ctx context.Context, secretRef string) (string, error) { 22 - _ = ctx 23 - 24 - envName := strings.TrimSpace(secretRef) 25 - if envName == "" { 26 - return "", fmt.Errorf("empty secret_ref") 27 - } 28 - 29 - val, ok := os.LookupEnv(envName) 30 - if !ok { 31 - return "", fmt.Errorf("secret not found (env var %q is not set)", envName) 32 - } 33 - if strings.TrimSpace(val) == "" { 34 - return "", fmt.Errorf("secret is empty (env var %q)", envName) 35 - } 36 - return val, nil 37 - }
+4 -4
secrets/types.go
··· 11 11 ) 12 12 13 13 type Credential struct { 14 - Kind string `mapstructure:"kind"` 15 - SecretRef string `mapstructure:"secret_ref"` 14 + Kind string `mapstructure:"kind"` 15 + Secret string `mapstructure:"secret"` 16 16 } 17 17 18 18 type Allow struct { ··· 72 72 if strings.TrimSpace(p.Credential.Kind) == "" { 73 73 return fmt.Errorf("auth_profiles.%s.credential.kind is required", p.ID) 74 74 } 75 - if strings.TrimSpace(p.Credential.SecretRef) == "" { 76 - return fmt.Errorf("auth_profiles.%s.credential.secret_ref is required", p.ID) 75 + if strings.TrimSpace(p.Credential.Secret) == "" { 76 + return fmt.Errorf("auth_profiles.%s.credential.secret is required", p.ID) 77 77 } 78 78 79 79 if len(p.Allow.URLPrefixes) == 0 {
+3 -7
tools/builtin/url_fetch.go
··· 30 30 type URLFetchAuth struct { 31 31 AllowProfiles map[string]bool 32 32 Profiles *secrets.ProfileStore 33 - Resolver secrets.Resolver 34 33 } 35 34 36 35 type URLFetchTool struct { ··· 289 288 } 290 289 binding = b 291 290 292 - if t.Auth.Resolver == nil { 293 - return "", fmt.Errorf("auth_profile is enabled but secret resolver is not configured") 294 - } 295 - sec, err := t.Auth.Resolver.Resolve(reqCtx, p.Credential.SecretRef) 296 - if err != nil { 297 - return "", err 291 + sec := strings.TrimSpace(p.Credential.Secret) 292 + if sec == "" { 293 + return "", fmt.Errorf("auth_profile %q credential.secret is empty", authProfileID) 298 294 } 299 295 injectHeaderName = strings.TrimSpace(b.Inject.Name) 300 296 injectHeaderVal, err = formatInjectedSecret(b.Inject.Format, sec)
+2 -8
tools/builtin/url_fetch_auth_test.go
··· 14 14 ) 15 15 16 16 func TestURLFetchTool_AuthProfileInjectsHeader(t *testing.T) { 17 - t.Setenv("TEST_API_KEY", "shh_secret") 18 17 19 18 type got struct { 20 19 Auth string ··· 42 41 tool := NewURLFetchToolWithAuth(true, 2*time.Second, 1024, "test-agent", t.TempDir(), &URLFetchAuth{ 43 42 AllowProfiles: map[string]bool{"p1": true}, 44 43 Profiles: secrets.NewProfileStore(map[string]secrets.AuthProfile{"p1": profile}), 45 - Resolver: &secrets.EnvResolver{}, 46 44 }) 47 45 tool.HTTPClient = &http.Client{Transport: rt} 48 46 ··· 64 62 } 65 63 66 64 func TestURLFetchTool_AuthProfileNotAllowlisted(t *testing.T) { 67 - t.Setenv("TEST_API_KEY", "shh_secret") 68 65 rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { 69 66 return &http.Response{ 70 67 StatusCode: 200, ··· 85 82 tool := NewURLFetchToolWithAuth(true, 2*time.Second, 1024, "test-agent", t.TempDir(), &URLFetchAuth{ 86 83 AllowProfiles: map[string]bool{}, // fail-closed 87 84 Profiles: secrets.NewProfileStore(map[string]secrets.AuthProfile{"p1": profile}), 88 - Resolver: &secrets.EnvResolver{}, 89 85 }) 90 86 tool.HTTPClient = &http.Client{Transport: rt} 91 87 ··· 122 118 } 123 119 124 120 func TestURLFetchTool_AuthProfileRedirectSameOrigin307(t *testing.T) { 125 - t.Setenv("TEST_API_KEY", "shh_secret") 126 121 127 122 type got struct { 128 123 Path string ··· 173 168 tool := NewURLFetchToolWithAuth(true, 2*time.Second, 1024, "test-agent", t.TempDir(), &URLFetchAuth{ 174 169 AllowProfiles: map[string]bool{"p1": true}, 175 170 Profiles: secrets.NewProfileStore(map[string]secrets.AuthProfile{"p1": profile}), 176 - Resolver: &secrets.EnvResolver{}, 177 171 }) 178 172 tool.HTTPClient = &http.Client{Transport: rt} 179 173 ··· 256 250 return secrets.AuthProfile{ 257 251 ID: id, 258 252 Credential: secrets.Credential{ 259 - Kind: "api_key", 260 - SecretRef: "TEST_API_KEY", 253 + Kind: "api_key", 254 + Secret: "shh_secret", 261 255 }, 262 256 Allow: secrets.Allow{ 263 257 URLPrefixes: []string{u.Scheme + "://" + u.Host + "/"},