diff --git a/docs/bedrock-configuration.md b/docs/bedrock-configuration.md new file mode 100644 index 000000000..92b833658 --- /dev/null +++ b/docs/bedrock-configuration.md @@ -0,0 +1,176 @@ +# AWS Bedrock Configuration for CodeLayer + +CodeLayer supports using Claude models running on AWS Bedrock or other Anthropic-compatible endpoints. + +## The Challenge: macOS App Launching + +When you launch CodeLayer from Spotlight, Raycast, or the Dock, macOS doesn't load your shell environment variables from `.zshrc` or `.bashrc`. This means environment variables like `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` won't be available to the app. + +## Solution: Configuration File + +The recommended way to configure Bedrock or custom endpoints is through CodeLayer's configuration file. + +### Quick Setup + +1. Create the configuration directory: + ```bash + mkdir -p ~/.config/humanlayer + ``` + +2. Create or edit `~/.config/humanlayer/humanlayer.json`: + ```json + { + "env": { + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + "ANTHROPIC_API_KEY": "your-aws-credentials-or-api-key" + } + } + ``` + +3. Set appropriate file permissions to protect your API key: + ```bash + chmod 600 ~/.config/humanlayer/humanlayer.json + ``` + +4. Launch CodeLayer normally (from Spotlight, Raycast, or Dock) + +### Configuration Options + +The configuration file supports environment variables through the `env` section. Any environment variables set here will be inherited by Claude sessions: + +- `ANTHROPIC_BASE_URL`: The base URL for the Anthropic API or compatible endpoint + - For AWS Bedrock: `https://bedrock-runtime..amazonaws.com` + - For custom endpoints: Your custom API endpoint + - Leave unset to use default Anthropic API + +- `ANTHROPIC_API_KEY`: The API key to use + - For AWS Bedrock: Your AWS access credentials (formatted appropriately) + - For custom endpoints: Your API key + - Leave unset if using system credentials + +- Any other environment variables: You can set additional environment variables that will be inherited by all Claude sessions (e.g., `AWS_REGION`, `AWS_PROFILE`, etc.) + +### Alternative: Environment Variables (Terminal Launch Only) + +If you always launch CodeLayer from a terminal, you can still use environment variables: + +```bash +# Set in your terminal +export ANTHROPIC_BASE_URL=https://bedrock-runtime.us-east-1.amazonaws.com +export ANTHROPIC_API_KEY=your-key + +# Launch CodeLayer from the same terminal +open /Applications/CodeLayer-Nightly.app +``` + +**Note**: This only works when launching from a terminal that has the environment variables set. It won't work if you later launch CodeLayer from Spotlight or Raycast. + +### Configuration Priority + +CodeLayer uses the following priority order (highest to lowest): + +1. Per-session configuration (if explicitly set) +2. Environment variables (if app was launched with them) +3. Configuration file (`~/.config/humanlayer/humanlayer.json`) +4. Default Anthropic API + +### AWS Bedrock Specific Configuration + +When using AWS Bedrock, you'll typically need to: + +1. Set up AWS credentials (via AWS CLI or environment variables) +2. Configure the Bedrock endpoint for your region +3. Ensure your AWS IAM role has permissions to invoke Bedrock models + +Example configuration for AWS Bedrock in `us-west-2`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-west-2.amazonaws.com", + "AWS_REGION": "us-west-2" + } +} +``` + +Then ensure your AWS credentials are available (via `~/.aws/credentials` or environment variables that CodeLayer will inherit). + +### Troubleshooting + +#### Configuration file not found +The daemon looks for configuration in these locations (in order): +1. `./humanlayer.json` (current directory) +2. `$XDG_CONFIG_HOME/humanlayer/humanlayer.json` +3. `~/.config/humanlayer/humanlayer.json` + +#### Changes not taking effect +Restart CodeLayer after modifying the configuration file: +1. Quit CodeLayer completely +2. Relaunch the app + +#### Verify configuration is loaded +Check the daemon logs to see if your configuration was loaded: +```bash +# The daemon logs show configuration loading +tail -f ~/Library/Logs/CodeLayer/daemon.log +``` + +Look for debug messages indicating environment variables were inherited from daemon configuration. Depending on the session type, you'll see one of these messages: +- `"inherited env var from daemon configuration"` - For new sessions (LaunchSession) +- `"inherited env var from daemon configuration for resumed session"` - For resumed sessions (ContinueSession) +- `"inherited generic env var from daemon configuration"` - For draft sessions (launchDraftWithConfig) + +Each log entry will include the session ID and the environment variable key (e.g., `ANTHROPIC_BASE_URL`). + +### Security Considerations + +**Important**: The configuration file contains sensitive credentials. Always: + +1. Set restrictive file permissions: + ```bash + chmod 600 ~/.config/humanlayer/humanlayer.json + ``` + +2. Never commit the configuration file to version control + +3. Consider using AWS IAM roles or credential management systems for production use + +### Full Configuration Example + +Here's a complete example configuration file with all available options: + +```json +{ + "socket_path": "~/.humanlayer/daemon.sock", + "database_path": "~/.humanlayer/daemon.db", + "log_level": "info", + "http_port": 7777, + "http_host": "127.0.0.1", + "claude_path": "/opt/homebrew/bin/claude", + "env": { + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-west-2.amazonaws.com", + "AWS_REGION": "us-west-2", + "AWS_PROFILE": "my-profile" + } +} +``` + +### Related Documentation + +- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) +- [Anthropic API Documentation](https://docs.anthropic.com/) +- [CodeLayer Configuration](./configuration.md) + +### Getting Help + +If you encounter issues with Bedrock configuration: + +1. Check that your AWS credentials are valid +2. Verify the Bedrock endpoint URL is correct for your region +3. Ensure your IAM role has necessary permissions +4. Check CodeLayer daemon logs for error messages + +For additional support, open an issue on GitHub with: +- Your configuration file (with API keys redacted) +- Relevant error messages from logs +- AWS region and model you're trying to use diff --git a/hld/config/config.go b/hld/config/config.go index 4d6938c52..2fc0d746c 100644 --- a/hld/config/config.go +++ b/hld/config/config.go @@ -1,7 +1,9 @@ package config import ( + "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strconv" @@ -43,6 +45,12 @@ type Config struct { // Claude configuration ClaudePath string `mapstructure:"claude_path"` + + // Generic environment variables to pass to all Claude sessions + // This allows setting arbitrary environment variables that will be inherited + // by all sessions, useful for custom configurations like ANTHROPIC_BASE_URL, + // ANTHROPIC_API_KEY, AWS_REGION, or any other environment-based configuration. + Env map[string]string `mapstructure:"env"` } // Load loads configuration with priority: flags > env vars > config file > defaults @@ -89,6 +97,52 @@ func Load() (*Config, error) { return nil, fmt.Errorf("error unmarshaling config: %w", err) } + // Viper/mapstructure lowercases all map keys, but environment variables are case-sensitive. + // We need to preserve the original case from the config file for the env map. + // Read the config file again and extract the env map with original case preserved. + // This is done defensively - if anything fails, we log a warning and use what Viper gave us. + configFile := v.ConfigFileUsed() + if configFile != "" && config.Env != nil { + data, err := os.ReadFile(configFile) + if err != nil { + slog.Warn("failed to read config file for env key case preservation, using lowercased keys", + "file", configFile, "error", err) + } else { + var rawConfig map[string]interface{} + if err := json.Unmarshal(data, &rawConfig); err != nil { + slog.Warn("failed to unmarshal config file for env key case preservation, using lowercased keys", + "file", configFile, "error", err) + } else { + envValue, ok := rawConfig["env"] + if !ok { + // This is fine - Viper populated config.Env but the raw JSON doesn't have it + // (shouldn't happen, but be defensive) + slog.Debug("config file does not contain 'env' key for case preservation", + "file", configFile) + } else if envMap, ok := envValue.(map[string]interface{}); !ok { + slog.Warn("config file 'env' value is not a map, using lowercased keys", + "file", configFile, "type", fmt.Sprintf("%T", envValue)) + } else { + // Collect case-preserved env vars + preservedEnv := make(map[string]string) + for k, v := range envMap { + str, ok := v.(string) + if !ok { + slog.Warn("config file env key has non-string value, skipping", + "file", configFile, "key", k, "type", fmt.Sprintf("%T", v)) + continue + } + preservedEnv[k] = str + } + // Only replace if we got at least one valid entry + if len(preservedEnv) > 0 { + config.Env = preservedEnv + } + } + } + } + } + // Expand home directory in paths config.SocketPath = expandHome(config.SocketPath) config.DatabasePath = expandHome(config.DatabasePath) @@ -178,6 +232,7 @@ func Save(cfg *Config) error { v.Set("http_port", cfg.HTTPPort) v.Set("http_host", cfg.HTTPHost) v.Set("claude_path", cfg.ClaudePath) + v.Set("env", cfg.Env) // Set config file path explicitly configFile := filepath.Join(configDir, "humanlayer.json") diff --git a/hld/config/config_test.go b/hld/config/config_test.go new file mode 100644 index 000000000..e88864d5c --- /dev/null +++ b/hld/config/config_test.go @@ -0,0 +1,309 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_EnvCasePreservation(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Create a config file with mixed-case environment variable keys + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + "ANTHROPIC_API_KEY": "test-key-123", + "AWS_REGION": "us-east-1", + "MixedCase_Var": "mixed-value" + } + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Set environment to use our test config + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load the configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify that environment variable keys preserve their case + testCases := []struct { + key string + value string + }{ + {"ANTHROPIC_BASE_URL", "https://bedrock-runtime.us-east-1.amazonaws.com"}, + {"ANTHROPIC_API_KEY", "test-key-123"}, + {"AWS_REGION", "us-east-1"}, + {"MixedCase_Var", "mixed-value"}, + } + + if cfg.Env == nil { + t.Fatal("Expected Env map to be initialized, got nil") + } + + for _, tc := range testCases { + got, ok := cfg.Env[tc.key] + if !ok { + t.Errorf("Expected env key %q to be present", tc.key) + continue + } + if got != tc.value { + t.Errorf("For key %q: expected value %q, got %q", tc.key, tc.value, got) + } + } + + // Verify the count of environment variables + if len(cfg.Env) != 4 { + t.Errorf("Expected 4 environment variables, got %d", len(cfg.Env)) + } +} + +func TestLoad_EnvEmpty(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Create a config file without env section + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db" + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Set environment to use our test config + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load the configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify that Env map is nil when not specified + if cfg.Env != nil { + t.Errorf("Expected Env to be nil when not in config, got: %v", cfg.Env) + } +} + +func TestLoad_EnvWithEmptyObject(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Create a config file with empty env object + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": {} + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Set environment to use our test config + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load the configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // With the current implementation, an empty env object in the config + // won't trigger the case preservation logic since viper unmarshals it as nil. + // This is acceptable behavior - if no env vars are set, the map can be nil. + // The important thing is that it doesn't cause errors. + if len(cfg.Env) != 0 { + t.Errorf("Expected Env to be nil or empty, got %d entries", len(cfg.Env)) + } +} + +func TestSaveAndLoad_EnvRoundTrip(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Override the default config dir for this test + origHome := os.Getenv("HOME") + if err := os.Setenv("HOME", tmpDir); err != nil { + t.Fatalf("Failed to set HOME env: %v", err) + } + defer func() { + _ = os.Setenv("HOME", origHome) + }() + + // Create a config with environment variables + cfg := &Config{ + SocketPath: "/tmp/test.sock", + DatabasePath: "/tmp/test.db", + LogLevel: "info", + HTTPPort: 7777, + HTTPHost: "127.0.0.1", + Env: map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key", + "MY_CUSTOM_VAR": "custom-value", + }, + } + + // Save the configuration + if err := Save(cfg); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Change to config directory + configDir := getDefaultConfigDir() + oldDir, _ := os.Getwd() + if err := os.Chdir(configDir); err != nil { + t.Fatalf("Failed to change to config dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load the configuration back + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify that environment variables are preserved + if loadedCfg.Env == nil { + t.Fatal("Expected Env map to be initialized after load, got nil") + } + + for key, expectedValue := range cfg.Env { + gotValue, ok := loadedCfg.Env[key] + if !ok { + t.Errorf("Expected env key %q to be present after round-trip", key) + continue + } + if gotValue != expectedValue { + t.Errorf("For key %q: expected value %q, got %q", key, expectedValue, gotValue) + } + } + + // Verify the count matches + if len(loadedCfg.Env) != len(cfg.Env) { + t.Errorf("Expected %d environment variables after round-trip, got %d", + len(cfg.Env), len(loadedCfg.Env)) + } +} + +func TestLoad_EnvWithSpecialCharacters(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Create a config file with special characters in values + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "URL_WITH_QUERY": "https://example.com?key=value&other=123", + "PATH_LIKE": "/path/to/some/file:another/path", + "WITH_SPACES": "value with spaces" + } + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Set environment to use our test config + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load the configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify that values with special characters are preserved correctly + testCases := []struct { + key string + value string + }{ + {"URL_WITH_QUERY", "https://example.com?key=value&other=123"}, + {"PATH_LIKE", "/path/to/some/file:another/path"}, + {"WITH_SPACES", "value with spaces"}, + } + + for _, tc := range testCases { + got, ok := cfg.Env[tc.key] + if !ok { + t.Errorf("Expected env key %q to be present", tc.key) + continue + } + if got != tc.value { + t.Errorf("For key %q: expected value %q, got %q", tc.key, tc.value, got) + } + } +} diff --git a/hld/config/env_precedence_test.go b/hld/config/env_precedence_test.go new file mode 100644 index 000000000..19f2d7660 --- /dev/null +++ b/hld/config/env_precedence_test.go @@ -0,0 +1,398 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEnvPrecedence_ConfigFileVsOSEnv tests the precedence between config file env +// and OS environment variables. +// +// The expected behavior when launching the daemon (e.g., via `open /Applications/CodeLayer-Nightly.app`): +// 1. Session-specific env (highest priority - set when launching a session via API) +// 2. Config file env vars (~/.config/humanlayer/humanlayer.json "env" field) +// 3. OS environment variables (lowest priority - set before launching the daemon) +// +// This test verifies that config file env values take precedence over OS env variables. +// This design is intentional because: +// - macOS apps launched from Spotlight/Raycast don't inherit shell environment +// - Config file provides a persistent, cross-platform configuration mechanism +// - Users launching via `open` command should get predictable config file behavior +func TestEnvPrecedence_ConfigFileVsOSEnv(t *testing.T) { + // Create a temporary directory for test config + tmpDir, err := os.MkdirTemp("", "config-precedence-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Scenario 1: Config file has ANTHROPIC_BASE_URL, OS env also has it + // Expected: Config file value should be used (config takes precedence) + t.Run("config_file_overrides_os_env", func(t *testing.T) { + // Set OS environment variable + oldEnv := os.Getenv("ANTHROPIC_BASE_URL") + defer func() { + if oldEnv != "" { + _ = os.Setenv("ANTHROPIC_BASE_URL", oldEnv) + } else { + _ = os.Unsetenv("ANTHROPIC_BASE_URL") + } + }() + _ = os.Setenv("ANTHROPIC_BASE_URL", "https://from-os-env.example.com") + + // Create config file with different value + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "ANTHROPIC_BASE_URL": "https://from-config-file.example.com", + "ANTHROPIC_API_KEY": "config-file-key-123" + } + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Change to temp directory to load config + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify config file value is loaded (not OS env value) + if cfg.Env == nil { + t.Fatal("Expected Env map to be initialized, got nil") + } + + gotURL, ok := cfg.Env["ANTHROPIC_BASE_URL"] + if !ok { + t.Fatal("Expected ANTHROPIC_BASE_URL to be present in config") + } + + expectedURL := "https://from-config-file.example.com" + if gotURL != expectedURL { + t.Errorf("Expected ANTHROPIC_BASE_URL from config file %q, got %q", expectedURL, gotURL) + } + + // Also verify the API key from config + gotKey, ok := cfg.Env["ANTHROPIC_API_KEY"] + if !ok { + t.Fatal("Expected ANTHROPIC_API_KEY to be present in config") + } + expectedKey := "config-file-key-123" + if gotKey != expectedKey { + t.Errorf("Expected ANTHROPIC_API_KEY from config file %q, got %q", expectedKey, gotKey) + } + }) + + // Scenario 2: Only OS env has value, config file doesn't + // Expected: Config should NOT automatically inherit OS env for generic keys + // (This is different from specific viper bindings like HUMANLAYER_API_KEY) + t.Run("config_file_missing_key_does_not_inherit_os_env", func(t *testing.T) { + // Set OS environment variables + oldURL := os.Getenv("ANTHROPIC_BASE_URL") + oldKey := os.Getenv("LINEAR_API_KEY") + defer func() { + if oldURL != "" { + _ = os.Setenv("ANTHROPIC_BASE_URL", oldURL) + } else { + _ = os.Unsetenv("ANTHROPIC_BASE_URL") + } + if oldKey != "" { + _ = os.Setenv("LINEAR_API_KEY", oldKey) + } else { + _ = os.Unsetenv("LINEAR_API_KEY") + } + }() + _ = os.Setenv("ANTHROPIC_BASE_URL", "https://from-os-env.example.com") + _ = os.Setenv("LINEAR_API_KEY", "linear-key-from-os") + + // Create config file WITHOUT these env vars in the "env" section + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db" + }` + + testDir := filepath.Join(tmpDir, "test2") + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test dir: %v", err) + } + + configPath := filepath.Join(testDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + oldDir, _ := os.Getwd() + if err := os.Chdir(testDir); err != nil { + t.Fatalf("Failed to change to test dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Config.Env should be nil or empty since we didn't specify "env" section + // OS environment variables are NOT automatically added to Config.Env + if len(cfg.Env) > 0 { + t.Errorf("Expected Env to be empty when not in config, got: %v", cfg.Env) + } + + // Note: The daemon can still access os.Getenv() directly if needed, + // but Config.Env only contains what's explicitly in the config file + }) + + // Scenario 3: Multiple env vars with different sources + t.Run("mixed_config_and_os_env", func(t *testing.T) { + // Set OS environment variable + oldOSKey := os.Getenv("OS_ONLY_VAR") + defer func() { + if oldOSKey != "" { + _ = os.Setenv("OS_ONLY_VAR", oldOSKey) + } else { + _ = os.Unsetenv("OS_ONLY_VAR") + } + }() + _ = os.Setenv("OS_ONLY_VAR", "from-os") + + // Create config with some env vars + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "CONFIG_ONLY_VAR": "from-config", + "ANTHROPIC_BASE_URL": "https://bedrock.amazonaws.com", + "LINEAR_API_KEY": "lin_abc123" + } + }` + + testDir := filepath.Join(tmpDir, "test3") + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test dir: %v", err) + } + + configPath := filepath.Join(testDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + oldDir, _ := os.Getwd() + if err := os.Chdir(testDir); err != nil { + t.Fatalf("Failed to change to test dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Env == nil { + t.Fatal("Expected Env map to be initialized, got nil") + } + + // Verify config file values are present + if got := cfg.Env["CONFIG_ONLY_VAR"]; got != "from-config" { + t.Errorf("Expected CONFIG_ONLY_VAR='from-config', got %q", got) + } + if got := cfg.Env["ANTHROPIC_BASE_URL"]; got != "https://bedrock.amazonaws.com" { + t.Errorf("Expected ANTHROPIC_BASE_URL from config, got %q", got) + } + if got := cfg.Env["LINEAR_API_KEY"]; got != "lin_abc123" { + t.Errorf("Expected LINEAR_API_KEY from config, got %q", got) + } + + // Verify OS-only var is NOT in Config.Env + if _, ok := cfg.Env["OS_ONLY_VAR"]; ok { + t.Error("OS_ONLY_VAR should not be in Config.Env") + } + + // Verify we only have the config file vars + if len(cfg.Env) != 3 { + t.Errorf("Expected 3 env vars from config, got %d: %v", len(cfg.Env), cfg.Env) + } + }) +} + +// TestEnvPrecedence_Documentation documents the full precedence chain for reference +func TestEnvPrecedence_Documentation(t *testing.T) { + t.Log("Environment Variable Precedence (highest to lowest):") + t.Log("1. Session-specific env (passed via LaunchSessionRequest.Env or similar)") + t.Log("2. Daemon config file env (~/.config/humanlayer/humanlayer.json 'env' field)") + t.Log("3. OS environment variables (only for specific viper-bound vars like HUMANLAYER_*)") + t.Log("") + t.Log("Example scenarios:") + t.Log("A. Launch with `open /Applications/CodeLayer-Nightly.app`:") + t.Log(" - App doesn't inherit shell env on macOS") + t.Log(" - Uses config file env for ANTHROPIC_BASE_URL, LINEAR_API_KEY, etc.") + t.Log("") + t.Log("B. Launch from terminal with env vars set:") + t.Log(" - If config file has 'env' section, those values override OS env") + t.Log(" - To use OS env instead, don't set those keys in config file") + t.Log("") + t.Log("C. Per-session overrides:") + t.Log(" - When creating a session via API, can pass session-specific env") + t.Log(" - These override both config file and daemon-level env") + t.Log("") + t.Log("Rationale:") + t.Log("- Config file precedence ensures consistent behavior across launch methods") + t.Log("- Session-level overrides enable per-project/per-session customization") + t.Log("- OS env as fallback maintains backward compatibility") +} + +// TestEnvPrecedence_RealWorldScenarios tests real-world usage patterns +func TestEnvPrecedence_RealWorldScenarios(t *testing.T) { + // Scenario: Using LINEAR_API_KEY + t.Run("linear_api_key_from_config_file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "config-linear-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Set OS env var for LINEAR_API_KEY + oldLinearKey := os.Getenv("LINEAR_API_KEY") + defer func() { + if oldLinearKey != "" { + _ = os.Setenv("LINEAR_API_KEY", oldLinearKey) + } else { + _ = os.Unsetenv("LINEAR_API_KEY") + } + }() + _ = os.Setenv("LINEAR_API_KEY", "lin_os_env_key_12345") + + // Create config file with LINEAR_API_KEY + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "LINEAR_API_KEY": "lin_config_file_key_67890", + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com" + } + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify config file value is used (not OS env) + gotKey, ok := cfg.Env["LINEAR_API_KEY"] + if !ok { + t.Fatal("Expected LINEAR_API_KEY to be present") + } + expectedKey := "lin_config_file_key_67890" + if gotKey != expectedKey { + t.Errorf("Expected LINEAR_API_KEY from config file %q, got %q", expectedKey, gotKey) + } + + // Verify ANTHROPIC_BASE_URL also from config + gotURL, ok := cfg.Env["ANTHROPIC_BASE_URL"] + if !ok { + t.Fatal("Expected ANTHROPIC_BASE_URL to be present") + } + expectedURL := "https://bedrock-runtime.us-east-1.amazonaws.com" + if gotURL != expectedURL { + t.Errorf("Expected ANTHROPIC_BASE_URL from config %q, got %q", expectedURL, gotURL) + } + }) + + // Scenario: Testing with AWS Bedrock configuration + t.Run("aws_bedrock_configuration", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "config-bedrock-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Create config file with Bedrock settings + configContent := `{ + "socket_path": "/tmp/test.sock", + "database_path": "/tmp/test.db", + "env": { + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + "ANTHROPIC_API_KEY": "bedrock-proxy-key", + "AWS_REGION": "us-east-1", + "AWS_PROFILE": "bedrock-profile" + } + }` + + configPath := filepath.Join(tmpDir, "humanlayer.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + defer func() { + _ = os.Chdir(oldDir) + }() + + // Load configuration + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify all Bedrock-related env vars are present + expectedEnvVars := map[string]string{ + "ANTHROPIC_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + "ANTHROPIC_API_KEY": "bedrock-proxy-key", + "AWS_REGION": "us-east-1", + "AWS_PROFILE": "bedrock-profile", + } + + for key, expectedValue := range expectedEnvVars { + gotValue, ok := cfg.Env[key] + if !ok { + t.Errorf("Expected env var %q to be present", key) + continue + } + if gotValue != expectedValue { + t.Errorf("For %q: expected %q, got %q", key, expectedValue, gotValue) + } + } + }) +} diff --git a/hld/go.mod b/hld/go.mod index b389dc725..edd92543e 100644 --- a/hld/go.mod +++ b/hld/go.mod @@ -17,6 +17,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 github.com/oapi-codegen/runtime v1.1.2 github.com/r3labs/sse/v2 v2.10.0 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/sahilm/fuzzy v0.1.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.5.2 @@ -44,6 +46,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -55,9 +58,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect diff --git a/hld/go.sum b/hld/go.sum index efe0818d7..72129803c 100644 --- a/hld/go.sum +++ b/hld/go.sum @@ -69,6 +69,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= diff --git a/hld/session/manager.go b/hld/session/manager.go index 3eae05a05..5fdb71f1f 100644 --- a/hld/session/manager.go +++ b/hld/session/manager.go @@ -29,9 +29,10 @@ type Manager struct { eventBus bus.EventBus store store.ConversationStore approvalReconciler ApprovalReconciler - pendingQueries sync.Map // map[sessionID]query - stores queries waiting for Claude session ID - socketPath string // Daemon socket path for MCP servers - httpPort int // HTTP server port for proxy endpoint + pendingQueries sync.Map // map[sessionID]query - stores queries waiting for Claude session ID + socketPath string // Daemon socket path for MCP servers + httpPort int // HTTP server port for proxy endpoint + env map[string]string // Generic environment variables to pass to all sessions } // Compile-time check that Manager implements SessionManager @@ -76,6 +77,7 @@ func NewManagerWithConfig(eventBus bus.EventBus, store store.ConversationStore, store: store, socketPath: socketPath, claudePath: cfg.ClaudePath, // Use configured Claude path + env: cfg.Env, } // Try to initialize Claude client but don't fail if unavailable @@ -357,6 +359,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig, "has_env_key", os.Getenv("OPENROUTER_API_KEY") != "") } + // Inherit environment variables from the daemon's configuration. + // This allows users to configure environment via config file + // (e.g., ~/.config/humanlayer/humanlayer.json) which is necessary because + // macOS apps launched from Spotlight/Raycast don't inherit shell environment. + // + // Precedence: daemon env < session-specific env overrides + if claudeConfig.Env == nil { + claudeConfig.Env = make(map[string]string) + } + + // Apply env vars from config (only if not already present in session config) + for key, value := range m.env { + if _, ok := claudeConfig.Env[key]; !ok { + claudeConfig.Env[key] = value + slog.Debug("inherited env var from daemon configuration", + "session_id", sessionID, + "key", key) + } + } + // Log final configuration before launching var mcpServersDetail string var mcpServerCount int @@ -1693,6 +1715,22 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig "has_openrouter_key", os.Getenv("OPENROUTER_API_KEY") != "") } + // Ensure config.Env is initialized before we inspect or assign keys. + if config.Env == nil { + config.Env = make(map[string]string) + } + + // Inherit environment variables from the daemon's configuration. + // Precedence: daemon env < session-specific env overrides + for key, value := range m.env { + if _, ok := config.Env[key]; !ok { + config.Env[key] = value + slog.Debug("inherited env var from daemon configuration for resumed session", + "session_id", sessionID, + "key", key) + } + } + // Get Claude client (will attempt initialization if needed) client, err := m.getClaudeClient() if err != nil { @@ -1924,6 +1962,27 @@ func (m *Manager) launchDraftWithConfig(ctx context.Context, sessionID, runID st claudeConfig.Env["ANTHROPIC_API_KEY"] = "proxy-handled" } + // Inherit environment variables from the daemon's configuration. + // This happens in three layers: + // 1. Generic env map from config (lowest priority) + // 2. Specific ANTHROPIC_* config fields (medium priority) + // 3. Session-specific env overrides (highest priority, already in claudeConfig.Env) + if claudeConfig.Env == nil { + claudeConfig.Env = make(map[string]string) + } + + // 1. Apply generic env vars from config (lowest priority) + for key, value := range m.env { + if _, ok := claudeConfig.Env[key]; !ok { + claudeConfig.Env[key] = value + slog.Debug("inherited generic env var from daemon configuration", + "session_id", sessionID, + "key", key) + } + } + + // ANTHROPIC_* variables are inherited via the generic env inheritance above + // Launch Claude session slog.Info("launching draft session with Claude", "session_id", sessionID,