diff --git a/internal/config/agents.go b/internal/config/agents.go index 08e8d654..d822fbaf 100644 --- a/internal/config/agents.go +++ b/internal/config/agents.go @@ -41,6 +41,11 @@ type AgentPresetInfo struct { // Args are the default command-line arguments for autonomous mode. Args []string `json:"args"` + // Env are environment variables to set when starting the agent. + // These are merged with the standard GT_* variables. + // Used for agent-specific configuration like OPENCODE_PERMISSION. + Env map[string]string `json:"env,omitempty"` + // ProcessNames are the process names to look for when detecting if the agent is running. // Used by tmux.IsAgentRunning to check pane_current_command. // E.g., ["node"] for Claude, ["cursor-agent"] for Cursor. @@ -318,7 +323,7 @@ func DefaultAgentPreset() AgentPreset { } // RuntimeConfigFromPreset creates a RuntimeConfig from an agent preset. -// This provides the basic Command/Args; additional fields from AgentPresetInfo +// This provides the basic Command/Args/Env; additional fields from AgentPresetInfo // can be accessed separately for extended functionality. func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig { info := GetAgentPreset(preset) @@ -327,9 +332,19 @@ func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig { return DefaultRuntimeConfig() } + // Copy Env map to avoid mutation + var envCopy map[string]string + if len(info.Env) > 0 { + envCopy = make(map[string]string, len(info.Env)) + for k, v := range info.Env { + envCopy[k] = v + } + } + rc := &RuntimeConfig{ Command: info.Command, Args: append([]string(nil), info.Args...), // Copy to avoid mutation + Env: envCopy, } // Resolve command path for claude preset (handles alias installations) diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go index ef5596f4..80f64dbf 100644 --- a/internal/config/agents_test.go +++ b/internal/config/agents_test.go @@ -102,6 +102,21 @@ func TestRuntimeConfigFromPreset(t *testing.T) { } } +func TestRuntimeConfigFromPresetReturnsNilEnvForPresetsWithoutEnv(t *testing.T) { + t.Parallel() + // Built-in presets like Claude don't have Env set + // This verifies nil Env handling in RuntimeConfigFromPreset + rc := RuntimeConfigFromPreset(AgentClaude) + if rc == nil { + t.Fatal("RuntimeConfigFromPreset returned nil") + } + + // Claude preset doesn't have Env, so it should be nil + if rc.Env != nil && len(rc.Env) > 0 { + t.Errorf("Expected nil/empty Env for Claude preset, got %v", rc.Env) + } +} + func TestIsKnownPreset(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/config/loader.go b/internal/config/loader.go index 87357df5..9af9aaa1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1085,6 +1085,13 @@ func fillRuntimeDefaults(rc *RuntimeConfig) *RuntimeConfig { Args: rc.Args, InitialPrompt: rc.InitialPrompt, } + // Copy Env map to avoid mutation and preserve agent-specific env vars + if len(rc.Env) > 0 { + result.Env = make(map[string]string, len(rc.Env)) + for k, v := range rc.Env { + result.Env[k] = v + } + } if result.Command == "" { result.Command = "claude" } @@ -1252,6 +1259,10 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri if rc.Session != nil && rc.Session.SessionIDEnv != "" { resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv } + // Merge agent-specific env vars (e.g., OPENCODE_PERMISSION for yolo mode) + for k, v := range rc.Env { + resolvedEnv[k] = v + } // Build environment export prefix var exports []string @@ -1362,6 +1373,10 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr if agentOverride != "" { resolvedEnv["GT_AGENT"] = agentOverride } + // Merge agent-specific env vars (e.g., OPENCODE_PERMISSION for yolo mode) + for k, v := range rc.Env { + resolvedEnv[k] = v + } // Build environment export prefix var exports []string diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 09a3be08..e99349e4 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -2777,3 +2777,97 @@ func TestBuildStartupCommandWithAgentOverride_NoGTAgentWhenNoOverride(t *testing t.Errorf("expected no GT_AGENT in command when no override, got: %q", cmd) } } + +func TestFillRuntimeDefaultsPreservesEnv(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input *RuntimeConfig + wantEnv map[string]string + wantNil bool + }{ + { + name: "nil input returns default", + input: nil, + wantNil: false, + }, + { + name: "preserves Env map", + input: &RuntimeConfig{ + Command: "test-cmd", + Env: map[string]string{ + "TEST_VAR": "test-value", + "JSON_VAR": `{"*":"allow"}`, + }, + }, + wantEnv: map[string]string{ + "TEST_VAR": "test-value", + "JSON_VAR": `{"*":"allow"}`, + }, + }, + { + name: "nil Env stays nil", + input: &RuntimeConfig{ + Command: "test-cmd", + Env: nil, + }, + wantEnv: nil, + }, + { + name: "empty Env stays empty", + input: &RuntimeConfig{ + Command: "test-cmd", + Env: map[string]string{}, + }, + wantEnv: nil, // Empty map is treated as nil (not copied) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := fillRuntimeDefaults(tt.input) + if result == nil { + if !tt.wantNil { + t.Fatal("fillRuntimeDefaults returned nil unexpectedly") + } + return + } + + if tt.wantEnv == nil { + if result.Env != nil && len(result.Env) > 0 { + t.Errorf("expected nil/empty Env, got %v", result.Env) + } + return + } + + if len(result.Env) != len(tt.wantEnv) { + t.Errorf("expected %d env vars, got %d", len(tt.wantEnv), len(result.Env)) + } + for k, want := range tt.wantEnv { + if got := result.Env[k]; got != want { + t.Errorf("Env[%s] = %q, want %q", k, got, want) + } + } + }) + } +} + +func TestFillRuntimeDefaultsEnvIsCopy(t *testing.T) { + t.Parallel() + original := &RuntimeConfig{ + Command: "test-cmd", + Env: map[string]string{ + "ORIGINAL": "value", + }, + } + + result := fillRuntimeDefaults(original) + + // Mutate the result + result.Env["MUTATED"] = "yes" + + // Original should be unchanged + if _, exists := original.Env["MUTATED"]; exists { + t.Error("Mutation of result.Env affected original config") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 9489e0de..8f7f3964 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -263,6 +263,11 @@ type RuntimeConfig struct { // Empty array [] means no args (not "use defaults"). Args []string `json:"args"` + // Env are environment variables to set when starting the agent. + // These are merged with the standard GT_* variables. + // Used for agent-specific configuration like OPENCODE_PERMISSION. + Env map[string]string `json:"env,omitempty"` + // InitialPrompt is an optional first message to send after startup. // For claude, this is passed as the prompt argument. // Empty by default (hooks handle context).