Skip to content

feat: support env:${VAR} references in apiKey field#6

Open
adm-humanerd wants to merge 1 commit into
rahadiana:mainfrom
adm-humanerd:feat/env-var-resolution
Open

feat: support env:${VAR} references in apiKey field#6
adm-humanerd wants to merge 1 commit into
rahadiana:mainfrom
adm-humanerd:feat/env-var-resolution

Conversation

@adm-humanerd

Copy link
Copy Markdown

Problem

The accounts.json config stores API keys in plaintext. Users who manage secrets via environment variables or a vault system (e.g. Drewgent vault, 1password CLI, pass, etc.) must either:

  1. Store keys in plaintext on disk (security concern)
  2. Write a wrapper that generates accounts.json dynamically (extra complexity)

Solution

Add a resolveValueRef() helper that detects the env: prefix in the apiKey field and resolves the value from process.env at runtime.

Example accounts.json:

{
  "accounts": [
    {
      "id": "my-p1",
      "provider": "anthropic",
      "apiKey": "env:ANTHROPIC_API_KEY_1",
      "priority": 1,
      "cooldownPeriod": 300000
    }
  ]
}

Implementation

  • resolveValueRef(value) — if value starts with env:, reads the rest as an env var name and returns process.env[varName]. Falls back to the literal value if the env var is not set (makes missing vars visible via auth errors).
  • normalizeAccount() — resolves account.apiKey before building credentials.env, so the stored env map always contains the actual secret.
  • resolveAccountEnv() — resolves all values in credentials.env before returning, and resolves account.apiKey in the legacy path. This ensures every consumer of resolveAccountEnv() — credential validation, shell/env injection, HTTP headers, and auth.json sync — gets the resolved value.

Testing

  • env:EXISTING_VAR → resolves to process.env[EXISTING_VAR]
  • env:MISSING_VAR → returns literal env:MISSING_VAR (fail-open for debugging)
  • sk-... (plaintext) → passes through unchanged
  • Combined with envVarName field for providers where the env var name differs from the inferred default.

Security

No plaintext secrets in config files. Keys are resolved at runtime from the process environment, which can be populated from any secret store (vault, keychain, 1password, etc.).

@adm-humanerd

Copy link
Copy Markdown
Author

✅ Rotation verification (env:VAR resolution)

Tested with 3 opencode-go API keys using env:OPENCODE_API_KEY_{1..3} references in accounts.json:

Key resolution

resolveValueRef("env:OPENCODE_API_KEY_1") → sk-SYp68ohQZ7... ✅
resolveValueRef("env:OPENCODE_API_KEY_2") → sk-TD8beTr48Z... ✅
resolveValueRef("env:OPENCODE_API_KEY_3") → sk-Anh7s09RGZ... ✅
resolveValueRef("sk-test")                → sk-test (passthrough) ✅
resolveValueRef("env:NONEXISTENT")         → "env:NONEXISTENT" (fail-open) ✅

Full rotation cycle

go-p1 → rate-limit → go-p2
  OPENCODE_API_KEY: sk-SYp68ohQZ7... → sk-TD8beTr48Z... ✅

go-p2 → rate-limit → go-p3  
  OPENCODE_API_KEY: sk-TD8beTr48Z... → sk-Anh7s09RGZ... ✅

go-p3 → rate-limit → opencode-go::4 (auth.json fallback)
  All 3 accounts exhausted, falls through to auto-discovered accounts ✅

With explicit envVarName

{
  "apiKey": "env:OPENCODE_API_KEY_1",
  "envVarName": "OPENCODE_API_KEY"
}

process.env.OPENCODE_API_KEY set to resolved value on each rotation ✅

@adm-humanerd

Copy link
Copy Markdown
Author

🔥 Additional fixes found during end-to-end testing

Bug 1: auth.json format — access vs key

buildAuthJsonEntry() was writing { type: "api", access: primary } but opencode expects { type: "api", key: primary }. This caused opencode-go provider to be invisible — no API key found.

Fixed in auth-json.js and storage.js (overwriteAuthJsonProvider fallback path).

Bug 2: Watchdog aborting sessions at 8s

The stream stall watchdog had an 8-second threshold. DeepSeek/thinking models regularly take longer than 8s before producing their first token. This caused random session aborts.

Increased to 120s in index.js.

Bug 3: _findAccountBySecret only checked access

After changing auth.json format to key, the token-comparison logic in universalSyncAuthJson() couldn't match accounts. Added key as primary field with access as fallback.

Fixed in account-manager.js (3 occurrences).

All fixes verified end-to-end ✅

@adm-humanerd

Copy link
Copy Markdown
Author

🐌 Root cause of response stalling found

Problem: Every API request triggered synchronous file I/O:

  1. getManagedAccount called manager.reload() — reads accounts.json, state.json, auth.json from disk
  2. shell.env + chat.headers hooks called overwriteAuthJsonProvider()writes to auth.json

On every single request: 3 file reads + 1 file write = synchronous blocking I/O.

Fixed:

  • Removed manager.reload() from getManagedAccount — accounts don't change mid-session
  • Removed overwriteAuthJsonProvider from both hooks — process.env and Authorization header are sufficient
  • Hooks now do zero disk I/O during active requests

- resolveValueRef(): resolves apiKey values prefixed with `env:`
  from process.env at runtime (normalizeAccount, resolveAccountEnv)
- auth.json: write `key` instead of `access` for api auth type
  (buildAuthJsonEntry, overwriteAuthJsonProvider, _findAccountBySecret)
- Remove console.log calls from account-manager.ts (stdout pollution)
@adm-humanerd
adm-humanerd force-pushed the feat/env-var-resolution branch from 12f49a9 to 693c6ca Compare July 15, 2026 07:40
@adm-humanerd

Copy link
Copy Markdown
Author

PR cleaned up

This PR now contains only the essential, non-controversial fixes:

  1. env:VAR resolutionresolveValueRef() detects env: prefix in apiKey and resolves from process.env
  2. auth.json format — use key field instead of access for api auth type (matches what opencode reads)
  3. console.log removal — 3 debug log lines removed from account-manager.ts (were polluting stdout/TUI)

The aggressive interceptor, watchdog, and I/O changes have been removed from this PR — those were local tuning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant