Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
5338955ae9 fix(daemon): bypass Gemini folder-trust gate in headless mode (#2516)
Gemini CLI's folder-trust feature throws FatalUntrustedWorkspaceError
(exit code 55) when the current workspace isn't in
`~/.gemini/trustedFolders.json` and the process is headless — no
interactive trust prompt is available. The daemon spawns gemini with
`-p` + `--yolo` in a freshly checked-out worktree that the user has
never trusted interactively, so every run with `security.folderTrust`
enabled fails after ~10s with exit status 55 and no useful output.

Default `GEMINI_CLI_TRUST_WORKSPACE=true` on the child env to short-
circuit `checkPathTrust` in gemini-core. This mirrors gemini-cli's
documented `--skip-trust` flag; the env var has been gemini's
documented headless escape hatch for the entire folder-trust feature
lifetime so the fix works on every gemini version that can produce
the crash. Callers that explicitly set the same key in cfg.Env win,
preserving the ability to opt back into the gate.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:55:27 +08:00
2 changed files with 86 additions and 1 deletions

View File

@@ -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)
}

View File

@@ -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()