From e259a725bdb1ffa7d8edaac65747896cad87ca99 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 20:19:02 +0900 Subject: [PATCH 1/2] fix(gemini): trust workspace for headless gemini-cli gemini-cli 0.60+ exits 55 (FatalUntrustedWorkspaceError) when folder trust is on and the Actions checkout is not in trustedFolders.json. -y auto-approves tools but does not bypass that gate. Set GEMINI_CLI_TRUST_WORKSPACE=true by default (ExtraEnv can override) and include a short CLI output tail in engine errors so bot comments show the real failure next time. Unblocks Sync #1/#2 and code#147 dogfood after Vertex preflight. --- internal/engine/gemini/gemini.go | 14 +++++++++- internal/engine/gemini/gemini_test.go | 37 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/internal/engine/gemini/gemini.go b/internal/engine/gemini/gemini.go index e59123d..de13a2c 100644 --- a/internal/engine/gemini/gemini.go +++ b/internal/engine/gemini/gemini.go @@ -76,7 +76,12 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu res := domain.RunResult{Role: req.Role, Summary: tail(strings.TrimSpace(string(out)), 40)} if runErr != nil { - res.Err = fmt.Errorf("gemini -p: %w", runErr) + msg := strings.TrimSpace(string(out)) + if msg != "" { + res.Err = fmt.Errorf("gemini -p: %w\n%s", runErr, tail(msg, 20)) + } else { + res.Err = fmt.Errorf("gemini -p: %w", runErr) + } return res, res.Err } return res, nil @@ -116,6 +121,13 @@ func (r *Runner) childEnv(workDir, credsPath string) []string { } else if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" { env = append(env, "GOOGLE_API_KEY="+k) } + // gemini-cli 0.60+ defaults folder trust on; headless CI checkouts are + // untrusted and exit 55 (FatalUntrustedWorkspaceError). -y does not bypass + // that gate — mirror unattended CI by trusting the workspace unless the + // caller already set the override. + if !hasEnvKey(r.ExtraEnv, "GEMINI_CLI_TRUST_WORKSPACE") { + env = append(env, "GEMINI_CLI_TRUST_WORKSPACE=true") + } return env } diff --git a/internal/engine/gemini/gemini_test.go b/internal/engine/gemini/gemini_test.go index 9e44b3d..92da4af 100644 --- a/internal/engine/gemini/gemini_test.go +++ b/internal/engine/gemini/gemini_test.go @@ -172,6 +172,40 @@ func TestRunDefaultsLocationWhenUnset(t *testing.T) { } } +const echoTrustEnv = `#!/bin/sh +printf '%s\n' "$GEMINI_CLI_TRUST_WORKSPACE" +exit 0 +` + +func TestRunTrustsWorkspaceForHeadlessCI(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, echoTrustEnv) + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Summary != "true" { + t.Fatalf("GEMINI_CLI_TRUST_WORKSPACE = %q, want true", res.Summary) + } +} + +func TestRunPreservesCallerTrustWorkspaceOverride(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, echoTrustEnv) + r.ExtraEnv = []string{"GEMINI_CLI_TRUST_WORKSPACE=false"} + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Summary != "false" { + t.Fatalf("caller override lost: %q", res.Summary) + } +} + func TestRunReportsAFailureAsAnEngineError(t *testing.T) { r := New() r.Bin = writeFakeGemini(t, "#!/bin/sh\necho boom >&2\nexit 3\n") @@ -184,6 +218,9 @@ func TestRunReportsAFailureAsAnEngineError(t *testing.T) { if !strings.Contains(res.Summary, "boom") { t.Fatalf("the CLI's own output should be kept: %q", res.Summary) } + if !strings.Contains(err.Error(), "boom") { + t.Fatalf("engine error should include CLI output: %v", err) + } } func TestRunRequiresAWorkDir(t *testing.T) { From 5c649a61399d32df6271df4d4b17c4e1d32b4a75 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 20:20:34 +0900 Subject: [PATCH 2/2] test(gemini): cover silent CLI failure error path Codecov patch flagged the empty-output branch of the exit-55 error formatting. Exercise a non-zero exit with no stdout/stderr. --- internal/engine/gemini/gemini_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/engine/gemini/gemini_test.go b/internal/engine/gemini/gemini_test.go index 92da4af..d63e5a6 100644 --- a/internal/engine/gemini/gemini_test.go +++ b/internal/engine/gemini/gemini_test.go @@ -223,6 +223,23 @@ func TestRunReportsAFailureAsAnEngineError(t *testing.T) { } } +func TestRunReportsSilentFailureAsAnEngineError(t *testing.T) { + r := New() + r.Bin = writeFakeGemini(t, "#!/bin/sh\nexit 3\n") + res, err := r.Run(context.Background(), domain.RunRequest{ + Role: domain.RoleImplementer, WorkDir: t.TempDir(), Prompt: "x", + }) + if err == nil || res.Err == nil { + t.Fatal("a non-zero exit must surface as an engine error") + } + if !strings.Contains(err.Error(), "exit status 3") { + t.Fatalf("silent failure error = %v", err) + } + if strings.Contains(err.Error(), "\n") { + t.Fatalf("empty CLI output should not add a detail line: %v", err) + } +} + func TestRunRequiresAWorkDir(t *testing.T) { if _, err := New().Run(context.Background(), domain.RunRequest{Role: domain.RoleImplementer}); err == nil { t.Fatal("expected an error with no WorkDir")