diff --git a/server/pkg/agent/gemini.go b/server/pkg/agent/gemini.go index 64e57876a1..b0a6571fb0 100644 --- a/server/pkg/agent/gemini.go +++ b/server/pkg/agent/gemini.go @@ -41,7 +41,7 @@ func (b *geminiBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if opts.Cwd != "" { cmd.Dir = opts.Cwd } - cmd.Env = buildEnv(b.cfg.Env) + cmd.Env = buildGeminiEnv(b.cfg.Env) stdout, err := cmd.StdoutPipe() if err != nil { @@ -265,3 +265,29 @@ func buildGeminiArgs(prompt string, opts ExecOptions, logger *slog.Logger) []str args = append(args, filterCustomArgs(opts.CustomArgs, geminiBlockedArgs, logger)...) return args } + +// buildGeminiEnv wraps buildEnv and defaults GEMINI_CLI_TRUST_WORKSPACE=true so +// gemini's folder-trust gate doesn't fail every headless daemon invocation with +// exit code 55 (FatalUntrustedWorkspaceError). When a user has enabled +// `security.folderTrust.enabled` in `~/.gemini/settings.json` and the daemon +// spawns gemini in a worktree that isn't pre-listed in `trustedFolders.json`, +// the CLI throws during startup warnings with no interactive prompt available, +// so the run fails after ~10s with no useful output (see #2516). +// +// The env-var bypass is gemini's own documented escape hatch (mirrors the +// `--skip-trust` CLI flag) and has been in place for the entire folder-trust +// feature lifetime, so this works on every gemini version that can produce the +// crash. If the caller explicitly sets the same key in cfg.Env it wins, +// preserving the ability to opt back into the check. +func buildGeminiEnv(extra map[string]string) []string { + const trustKey = "GEMINI_CLI_TRUST_WORKSPACE" + if _, ok := extra[trustKey]; ok { + return buildEnv(extra) + } + merged := make(map[string]string, len(extra)+1) + for k, v := range extra { + merged[k] = v + } + merged[trustKey] = "true" + return buildEnv(merged) +} diff --git a/server/pkg/agent/gemini_test.go b/server/pkg/agent/gemini_test.go index d23fae8900..fe5469d881 100644 --- a/server/pkg/agent/gemini_test.go +++ b/server/pkg/agent/gemini_test.go @@ -2,6 +2,7 @@ package agent import ( "log/slog" + "strings" "testing" ) @@ -91,6 +92,64 @@ func TestBuildGeminiArgsPassesThroughCustomArgs(t *testing.T) { } } +// envLookup returns the value of key in an env slice, or ("", false) if absent. +// When the key appears multiple times the last occurrence wins, mirroring how +// libc's getenv resolves duplicates on the daemon's supported platforms — the +// caller-supplied override therefore takes precedence over our default. +func envLookup(env []string, key string) (string, bool) { + prefix := key + "=" + var value string + var found bool + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + value = strings.TrimPrefix(entry, prefix) + found = true + } + } + return value, found +} + +func TestBuildGeminiEnvSetsTrustWorkspaceDefault(t *testing.T) { + t.Parallel() + + env := buildGeminiEnv(nil) + got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE") + if !ok { + t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env) + } + if got != "true" { + t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE=true, got %q", got) + } +} + +func TestBuildGeminiEnvRespectsExplicitOverride(t *testing.T) { + t.Parallel() + + // Users who deliberately set the value (e.g. to "false" to opt back into + // gemini's folder-trust gate, or to a future-proofed value) must win over + // our daemon default. + env := buildGeminiEnv(map[string]string{"GEMINI_CLI_TRUST_WORKSPACE": "false"}) + got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE") + if !ok { + t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env) + } + if got != "false" { + t.Fatalf("expected caller's GEMINI_CLI_TRUST_WORKSPACE=false to win, got %q", got) + } +} + +func TestBuildGeminiEnvPreservesOtherExtras(t *testing.T) { + t.Parallel() + + env := buildGeminiEnv(map[string]string{"GEMINI_API_KEY": "secret"}) + if got, ok := envLookup(env, "GEMINI_API_KEY"); !ok || got != "secret" { + t.Fatalf("expected GEMINI_API_KEY=secret to pass through, got %q (ok=%v)", got, ok) + } + if got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE"); !ok || got != "true" { + t.Fatalf("expected default GEMINI_CLI_TRUST_WORKSPACE=true, got %q (ok=%v)", got, ok) + } +} + func TestBuildGeminiArgsFiltersBlockedCustomArgs(t *testing.T) { t.Parallel()