Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ src/
│ ├── api-key-validation.js # Validation endpoints per provider
│ ├── auth-json.js # Known provider IDs that map to sidecar's PROVIDER_ENV_MAP
│ ├── config.js # Default model alias map — short names to full OpenRouter model identifiers
│ ├── env-loader.js # Credential Loader
│ ├── idle-watchdog.js # @type {Object.<string, number>} Default timeouts per mode in milliseconds
│ ├── input-validators.js # MCP input validation with structured error responses.
│ ├── logger.js # Structured Logger Module
Expand Down Expand Up @@ -225,10 +226,11 @@ evals/
| `sidecar/start.js` | Generate a unique 8-character hex task ID | `generateTaskId()`, `createSessionMetadata()`, `buildMcpConfig()`, `checkElectronAvailable()`, `runInteractive()` |
| `utils/agent-mapping.js` | * All OpenCode native agent names (lowercase) | `PRIMARY_AGENTS()`, `OPENCODE_AGENTS()`, `HEADLESS_SAFE_AGENTS()`, `mapAgentToOpenCode()`, `isValidAgent()` |
| `utils/alias-resolver.js` | Alias Resolver Utilities | `applyDirectApiFallback()`, `autoRepairAlias()` |
| `utils/api-key-store.js` | Maps provider IDs to environment variable names | `getEnvPath()`, `readApiKeys()`, `readApiKeyHints()`, `readApiKeyValues()`, `saveApiKey()` |
| `utils/api-key-store.js` | Maps provider IDs to environment variable names | `getEnvPath()`, `loadEnvEntries()`, `readApiKeys()`, `readApiKeyHints()`, `readApiKeyValues()` |
| `utils/api-key-validation.js` | Validation endpoints per provider | `validateApiKey()`, `validateOpenRouterKey()`, `VALIDATION_ENDPOINTS()` |
| `utils/auth-json.js` | Known provider IDs that map to sidecar's PROVIDER_ENV_MAP | `readAuthJsonKeys()`, `importFromAuthJson()`, `checkAuthJson()`, `removeFromAuthJson()`, `AUTH_JSON_PATH()` |
| `utils/config.js` | Default model alias map — short names to full OpenRouter model identifiers | `getConfigDir()`, `getConfigPath()`, `loadConfig()`, `saveConfig()`, `getDefaultAliases()` |
| `utils/env-loader.js` | Credential Loader | `loadCredentials()` |
| `utils/idle-watchdog.js` | @type {Object.<string, number>} Default timeouts per mode in milliseconds | `IdleWatchdog()`, `resolveTimeout()` |
| `utils/input-validators.js` | MCP input validation with structured error responses. | `validateStartInputs()`, `findSimilar()` |
| `utils/logger.js` | Structured Logger Module | `logger()`, `LOG_LEVELS()` |
Expand Down
13 changes: 3 additions & 10 deletions bin/sidecar.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,9 @@
* Routes commands to appropriate handlers.
*/

const path = require('path');

// Load API keys from ~/.config/sidecar/.env (single source of truth)
const homeDir = process.env.HOME || process.env.USERPROFILE;
require('dotenv').config({ path: path.join(homeDir, '.config', 'sidecar', '.env'), quiet: true });

// Migrate legacy env var: GEMINI_API_KEY -> GOOGLE_GENERATIVE_AI_API_KEY
if (process.env.GEMINI_API_KEY && !process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
process.env.GOOGLE_GENERATIVE_AI_API_KEY = process.env.GEMINI_API_KEY;
}
// Load API keys from all sources: process.env > sidecar .env > auth.json
const { loadCredentials } = require('../src/utils/env-loader');
loadCredentials();

const { parseArgs, validateStartArgs, getUsage } = require('../src/cli');
const { validateTaskId } = require('../src/utils/validators');
Expand Down
98 changes: 98 additions & 0 deletions docs/superpowers/specs/2026-03-14-shell-independent-keys-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Shell-Independent API Key Resolution

**Issue:** [#11](https://github.com/jrenaldi79/sidecar/issues/11) - Sidecar fails with 'Missing Authentication header' in non-interactive shells
**Date:** 2026-03-14
**Status:** Draft

## Problem

Sidecar CLI fails when API keys are exported in `~/.zshrc` but not `~/.zshenv`. Non-interactive shells (Claude Code's Bash tool, CI/CD, cron) don't source `~/.zshrc`, so `process.env` lacks the keys.

The irony: sidecar already has two shell-independent key stores (`~/.config/sidecar/.env` and `~/.local/share/opencode/auth.json`), but doesn't load them into `process.env` early enough. The validator checks `process.env` first and fails before reaching fallback logic.

## Design

### New Module: `src/utils/env-loader.js`

Single exported function `loadCredentials()` that projects all credential sources into `process.env` with deterministic priority:

```
1. process.env (already set) <- highest, never overwritten
2. ~/.config/sidecar/.env <- user-configured via `sidecar setup`
3. ~/.local/share/opencode/auth.json <- OpenCode SDK fallback
```
Comment on lines +19 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks.

Both fenced blocks should include a language/info string to satisfy MD040.

Proposed fix
-```
+```text
 1. process.env (already set)     <- highest, never overwritten
 2. ~/.config/sidecar/.env        <- user-configured via `sidecar setup`
 3. ~/.local/share/opencode/auth.json  <- OpenCode SDK fallback

@@
- +text
Error: GOOGLE_GENERATIVE_AI_API_KEY not found.
@@

  • Add key to ~/.local/share/opencode/auth.json

Also applies to: 50-58

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 19-19: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/specs/2026-03-14-shell-independent-keys-design.md` around
lines 19 - 23, Add a language/info string "text" to the fenced code blocks that
show the env precedence list (the block starting with "1. process.env (already
set)") and the block starting "Error: GOOGLE_GENERATIVE_AI_API_KEY not found."
(and the other occurrence around lines 50-58) so the fences read ```text instead
of ```, satisfying MD040; locate these by searching for those exact block
contents and update each opening fence only.


Behavior:
- Per-provider, first source wins. Existing `process.env` values are never overwritten.
- Respects `SIDECAR_ENV_DIR` override for `.env` path (via `getEnvPath()` from `api-key-store.js`).
- Uses existing `parseEnvContent()` from `api-key-store.js` for `.env` parsing (no separate `dotenv` dependency needed).
- Handles `LEGACY_KEY_NAMES` migration (e.g., `GEMINI_API_KEY` to `GOOGLE_GENERATIVE_AI_API_KEY`), consolidating the migration that currently lives in both `bin/sidecar.js` and `api-key-store.js`.
- Auth.json keys are loaded in-memory only (no writes back to `.env`).
- Info-level logging when a key is loaded: `"Loaded GOOGLE_GENERATIVE_AI_API_KEY from sidecar .env"` (key values are never logged). Info-level is intentional: users hitting this bug are unlikely to have `LOG_LEVEL=debug`.
- Gracefully handles missing files (no error if `.env` or `auth.json` don't exist).

**Relationship to existing `resolveKeyValue()` in `api-key-store.js`:** The existing function gives `.env` file priority over `process.env`. After `loadCredentials()` projects all sources into `process.env`, `resolveKeyValue()` remains correct for its use case (setup UI display), but runtime validation now uses `process.env` as the single source of truth. No conflict because `loadCredentials()` never overwrites existing `process.env` values.

**Known limitation:** `auth.json` path (`~/.local/share/opencode/auth.json`) follows the Linux XDG layout. This matches OpenCode's own behavior and is not a regression.

### Update: `bin/sidecar.js`

Replace the existing early dotenv load and legacy key migration (lines 14-19) with a single `loadCredentials()` call before any validation or command dispatch. This consolidates three scattered concerns (dotenv loading, legacy migration, auth.json import) into one call.

**MCP entry point:** `sidecar mcp` dispatches through `bin/sidecar.js` via `handleMcp()`, so `loadCredentials()` runs before any MCP tool handler touches API keys. No separate MCP-specific loading needed.

### Update: `src/utils/validators.js`

Remove the special auth.json existence check from `validateApiKey()`. After `loadCredentials()` runs, all available keys are in `process.env`, so the validator becomes a pure `process.env` check. No fallback logic needed.

Improved error message when keys are truly missing:

```
Error: GOOGLE_GENERATIVE_AI_API_KEY not found.

In non-interactive shells (Claude Code, CI), ~/.zshrc is not sourced.
Fix with one of:
- Run `sidecar setup` to store keys in sidecar's config
- Move your export to ~/.zshenv (sourced by all zsh shells)
- Add key to ~/.local/share/opencode/auth.json
```

### Update: Documentation

- `skill/SKILL.md`: Add note under "Option B (Direct)" warning zsh users that `~/.zshrc` exports only work in interactive terminals. Recommend `~/.zshenv` or `sidecar setup`.
- Add troubleshooting entry for this specific scenario.

## What We're NOT Doing

- **No sourcing shell profiles.** Executing `~/.zshenv` or `~/.zshrc` from Node.js is fragile, non-portable (bash vs zsh vs fish), and may have side effects or hang in CI. Both Gemini and GPT independently flagged this as "architecturally leaky."
- **No writing back to `.env` from auth.json.** Importing auth.json keys is in-memory only for the current process. Avoids side-effect writes during simple commands like `sidecar list`.
- **No loading arbitrary `.env` from cwd.** Only sidecar's own config directory `.env` is loaded.

## Testing

### Unit Tests: `tests/utils/env-loader.test.js`

- Priority order: `process.env` > `.env` file > `auth.json`
- No-overwrite: existing `process.env` values are preserved
- Missing files: graceful handling when `.env` or `auth.json` don't exist
- `SIDECAR_ENV_DIR` override: respects custom `.env` path
- Per-provider merge: one key from `.env`, another from `auth.json`
- Security: file permissions, no secret logging

### Update: `tests/utils/validators.test.js`

- Remove tests for auth.json special-case fallback in `validateApiKey()`
- Add test for improved error message content
- Verify validation passes when keys come from `.env` (loaded via `loadCredentials()`)

### Integration Test

- Mocked integration test that calls `loadCredentials()` + `validateApiKey()` with a stubbed `process.env` and temp `.env` file. No real API call needed; just verify the key reaches `process.env` and validation passes.

## Multi-Model Review

Design reviewed by Gemini and GPT-4 via sidecar. Both independently recommended Option B with the same priority order. Key feedback incorporated:
- Reject Option C (sourcing shell profiles) as fragile and unsafe
- Centralize loading in a single module
- Fix the "lying validator" that checks auth.json existence without loading keys
- Keep auth.json imports in-memory only
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ module.exports = {
testMatch: ['**/tests/**/*.test.js'],
testPathIgnorePatterns: [
'/node_modules/',
'\\.integration\\.test\\.js$'
'\\.integration\\.test\\.js$',
'\\.worktrees/'
],
collectCoverageFrom: [
'src/**/*.js',
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
"scripts": {
"start": "node --experimental-top-level-await --experimental-vm-modules bin/sidecar.js",
"test": "jest",
"test:integration": "jest --testPathIgnorePatterns='[]' --testMatch='**/tests/**/*.integration.test.js'",
"test:all": "jest --testPathIgnorePatterns='/node_modules/' && git rev-parse HEAD > .test-passed 2>/dev/null || true",
"test:integration": "jest --testPathIgnorePatterns='\\.worktrees/' --testMatch='**/tests/**/*.integration.test.js'",
"test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='\\.worktrees/' && git rev-parse HEAD > .test-passed 2>/dev/null || true",
"test:e2e:mcp": "jest tests/mcp-repomix-e2e.integration.test.js --testTimeout=180000 --forceExit",
"posttest": "git rev-parse HEAD > .test-passed 2>/dev/null || true",
"lint": "eslint src/",
Expand Down
11 changes: 11 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export ANTHROPIC_API_KEY=your-anthropic-api-key

Add these to your shell profile (`~/.bashrc`, `~/.zshrc`) for persistence.

**zsh users:** `~/.zshrc` is only sourced by interactive shells. If you use sidecar from Claude Code, CI, or scripts, either:
- Run `sidecar setup` to store keys in sidecar's config (recommended)
- Move your exports to `~/.zshenv` (sourced by all zsh shell types)

**Model names with direct API keys:**
When using direct API keys, use the provider/model format WITHOUT the `openrouter/` prefix:
```bash
Expand Down Expand Up @@ -867,6 +871,13 @@ The mutex approach looks correct. Add tests."

## Troubleshooting

### "Missing Authentication header" in Claude Code or CI

API keys in `~/.zshrc` are not available in non-interactive shells. Fix:
1. Run `sidecar setup` (stores keys in `~/.config/sidecar/.env`)
2. Or move exports to `~/.zshenv`
3. Or add credentials to `~/.local/share/opencode/auth.json`

### "No Claude Code conversation history found"

Your project path encoding may not match. Check:
Expand Down
1 change: 1 addition & 0 deletions src/utils/api-key-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ function removeApiKey(provider) {

module.exports = {
getEnvPath,
loadEnvEntries,
readApiKeys,
readApiKeyHints,
readApiKeyValues,
Expand Down
53 changes: 53 additions & 0 deletions src/utils/env-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Credential Loader
*
* Loads API keys from multiple sources into process.env at CLI bootstrap.
* Priority: process.env (already set) > sidecar .env > auth.json
* Never overwrites existing process.env values.
*/
const { logger } = require('./logger');
const { loadEnvEntries, PROVIDER_ENV_MAP, LEGACY_KEY_NAMES } = require('./api-key-store');
const { readAuthJsonKeys } = require('./auth-json');
Comment on lines +8 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "package.json module type:"
jq -r '.type // "<missing>"' package.json

echo
echo "CommonJS usage in changed src/bin files:"
rg -nP --type=js '\b(require\s*\(|module\.exports\b)' src/utils/env-loader.js bin/sidecar.js

Repository: jrenaldi79/sidecar

Length of output: 1582


🏁 Script executed:

# Check the three imported modules
echo "=== logger.js (first 15 lines) ==="
head -15 src/utils/logger.js

echo
echo "=== api-key-store.js (first 15 lines) ==="
head -15 src/utils/api-key-store.js

echo
echo "=== auth-json.js (first 15 lines) ==="
head -15 src/utils/auth-json.js

echo
echo "=== Check for module.exports in dependencies ==="
rg -n 'module\.exports|export ' src/utils/{logger,api-key-store,auth-json}.js

echo
echo "=== Check if CLAUDE.md was updated ==="
rg 'env-loader|env_loader' CLAUDE.md || echo "No mention of env-loader in CLAUDE.md"

Repository: jrenaldi79/sidecar

Length of output: 1998


Convert to ESM as part of broader package migration.

This file uses CommonJS (require/module.exports) in a src/ directory, violating the ESM-only guideline. However, converting only this file is not feasible—all three imported modules (logger, api-key-store, auth-json) are currently CommonJS, and package.json lacks "type": "module".

To complete the conversion:

  • Set "type": "module" in package.json
  • Convert logger.js, api-key-store.js, and auth-json.js to ESM exports
  • Update env-loader.js to use ESM imports (as proposed in original comment)

CLAUDE.md was correctly updated with env-loader.js documentation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/env-loader.js` around lines 8 - 10, Convert the repository to ESM
so env-loader.js can be migrated: add "type": "module" to package.json, update
logger.js, api-key-store.js, and auth-json.js to export via ESM (export default
/ named exports) and adjust their internal require/module.exports usage, then
change env-loader.js to use import statements for logger, loadEnvEntries,
PROVIDER_ENV_MAP, LEGACY_KEY_NAMES, and readAuthJsonKeys; ensure exported symbol
names match (or add named exports) so env-loader.js can import them directly.


/**
* Load credentials from all sources into process.env.
* Call once at CLI startup, before any validation.
*
* Sources (in priority order):
* 1. process.env - already set, never overwritten
* 2. ~/.config/sidecar/.env - user-configured via `sidecar setup`
* 3. ~/.local/share/opencode/auth.json - OpenCode SDK fallback
*/
function loadCredentials() {
// Step 1: Load from sidecar .env file
const fileEntries = loadEnvEntries();
for (const [, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
if (!process.env[envVar]) {
const fromFile = fileEntries.get(envVar);
if (fromFile && fromFile.length > 0) {
process.env[envVar] = fromFile;
logger.info(`Loaded ${envVar} from sidecar .env`);
}
}
}

// Step 1b: Handle legacy key names in process.env
for (const [oldName, newName] of Object.entries(LEGACY_KEY_NAMES)) {
if (process.env[oldName] && !process.env[newName]) {
process.env[newName] = process.env[oldName];
logger.info(`Migrated ${oldName} to ${newName}`);
}
}

// Step 2: Import from auth.json (lowest priority)
const authKeys = readAuthJsonKeys();
for (const [provider, key] of Object.entries(authKeys)) {
const envVar = PROVIDER_ENV_MAP[provider];
if (envVar && !process.env[envVar]) {
process.env[envVar] = key;
logger.info(`Loaded ${envVar} from auth.json`);
}
}
}

module.exports = { loadCredentials };
23 changes: 9 additions & 14 deletions src/utils/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,10 @@ const { validateMcpSpec, validateMcpConfigFile } = require('./mcp-validators');
const { MODEL_THINKING_SUPPORT, getSupportedThinkingLevels, validateThinkingLevel } = require('./thinking-validators');

/**
* Validate API key is present for the given model's provider
* Validate API key is present for the given model's provider.
*
* NOTE: OpenCode manages its own credentials via `opencode auth`.
* The env var check is a convenience hint, not a hard requirement.
* If OpenCode has credentials configured, the model will work
* even without the env var set.
* Assumes loadCredentials() has already run, projecting all credential
* sources (sidecar .env, auth.json) into process.env.
*
* @param {string} model - The model string (e.g., 'openrouter/google/gemini-2.5-flash')
* @returns {{valid: boolean, error?: string}}
Expand All @@ -249,17 +247,14 @@ function validateApiKey(model) {
}

if (!process.env[providerInfo.key]) {
// Check if OpenCode has credentials configured (auth.json)
const os = require('os');
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
if (fs.existsSync(authPath)) {
// OpenCode manages its own auth — skip env var check
return { valid: true };
}

return {
valid: false,
error: `Error: ${providerInfo.key} environment variable is required for ${providerInfo.name} models. Set it with: export ${providerInfo.key}=your-api-key`
error: `Error: ${providerInfo.key} not found.\n\n` +
'In non-interactive shells (Claude Code, CI), ~/.zshrc is not sourced.\n' +
'Fix with one of:\n' +
' - Run `sidecar setup` to store keys in sidecar\'s config\n' +
' - Move your export to ~/.zshenv (sourced by all zsh shells)\n' +
' - Add key to ~/.local/share/opencode/auth.json'
Comment on lines +252 to +257
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid hardcoding a single auth.json path in the remediation text.

The message currently implies only one location. In non-default setups this can misdirect users; make it explicitly “default path” or generic.

Suggested wording tweak
-        '  - Add key to ~/.local/share/opencode/auth.json'
+        '  - Add key to your OpenCode auth.json (default: ~/.local/share/opencode/auth.json)'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error: `Error: ${providerInfo.key} not found.\n\n` +
'In non-interactive shells (Claude Code, CI), ~/.zshrc is not sourced.\n' +
'Fix with one of:\n' +
' - Run `sidecar setup` to store keys in sidecar\'s config\n' +
' - Move your export to ~/.zshenv (sourced by all zsh shells)\n' +
' - Add key to ~/.local/share/opencode/auth.json'
error: `Error: ${providerInfo.key} not found.\n\n` +
'In non-interactive shells (Claude Code, CI), ~/.zshrc is not sourced.\n' +
'Fix with one of:\n' +
' - Run `sidecar setup` to store keys in sidecar\'s config\n' +
' - Move your export to ~/.zshenv (sourced by all zsh shells)\n' +
' - Add key to your OpenCode auth.json (default: ~/.local/share/opencode/auth.json)'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/validators.js` around lines 252 - 257, The remediation text that
builds the error string for providerInfo.key currently hardcodes a single
auth.json location; update that message to avoid implying a single path by
referring to the "default auth.json path" or making it explicit that the shown
path is an example (e.g. "or add key to the default auth.json path (e.g.
~/.local/share/opencode/auth.json)"). Modify the error string construction
around the code that references providerInfo.key so the third bullet becomes
generic wording like "or add key to the default auth.json path (for example:
~/.local/share/opencode/auth.json)", preserving providerInfo.key interpolation
and the other bullets.

};
}

Expand Down
Loading