feat(config): add Env field to RuntimeConfig and AgentPresetInfo (#860)
Add support for agent presets to specify environment variables that
get exported when starting sessions. This enables agents to use
environment-based configuration.
Changes:
- Add Env field to RuntimeConfig struct in types.go
- Add Env field to AgentPresetInfo struct in agents.go
- Update RuntimeConfigFromPreset to copy Env from preset
- Update fillRuntimeDefaults to preserve Env field
- Merge agent Env vars in BuildStartupCommand functions
- Add comprehensive tests for Env preservation and copy semantics
This is a prerequisite for the OpenCode agent preset which uses
OPENCODE_PERMISSION='{"*":"allow"}' for auto-approve mode.
Co-authored-by: Avyukth <subhrajit.makur@hotmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user