Skip to content
39 changes: 34 additions & 5 deletions .github/workflows/smoke-gemini.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-gemini.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
pull_request:
types: [labeled]
names: ["water"]
reaction: "rocket"
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The addition of reaction: "rocket" in the workflow frontmatter appears unrelated to the main purpose of this PR (Gemini diagnostics improvements). While this change is valid and generates the expected "Add rocket reaction" step in the lock file, consider whether it should be included in this PR or moved to a separate commit for clarity.

Suggested change
reaction: "rocket"

Copilot uses AI. Check for mistakes.
status-comment: true
permissions:
contents: read
Expand All @@ -14,7 +15,6 @@ permissions:
name: Smoke Gemini
engine:
id: gemini
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model field has been removed from the Gemini engine config, allowing it to use the default/dynamic model selection at runtime. This is consistent with how other engines handle model configuration via GH_AW_MODEL_AGENT_CUSTOM. ✅

model: gemini-2.0-flash-lite
strict: true
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of model: gemini-2.0-flash-lite from the engine configuration is intentional based on the PR description, but this represents a behavior change. Verify that the Gemini CLI's built-in default model is appropriate for the smoke test workflow. If the smoke test requires a specific model for consistency or cost control, consider keeping an explicit model configuration.

Suggested change
strict: true
model: gemini-2.0-flash-lite
strict: true

Copilot uses AI. Check for mistakes.
imports:
- shared/gh.md
Expand Down
69 changes: 69 additions & 0 deletions pkg/workflow/agentic_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,21 @@ func TestEngineOutputFileDeclarations(t *testing.T) {
t.Errorf("Codex engine should declare /tmp/gh-aw/mcp-config/logs/, got: %v", codexOutputFiles[0])
}

// Test Gemini engine declares output files for error log collection
geminiEngine := NewGeminiEngine()
geminiOutputFiles := geminiEngine.GetDeclaredOutputFiles()

if len(geminiOutputFiles) == 0 {
t.Error("Gemini engine should declare output files for error log collection")
}

if len(geminiOutputFiles) > 0 && geminiOutputFiles[0] != "/tmp/gemini-client-error-*.json" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good coverage for Gemini-specific error log collection. The glob pattern /tmp/gemini-client-error-*.json ensures all error files from a run are captured as artifacts for debugging. 🔍

t.Errorf("Gemini engine should declare /tmp/gemini-client-error-*.json, got: %v", geminiOutputFiles[0])
}

t.Logf("Claude engine declares: %v", claudeOutputFiles)
t.Logf("Codex engine declares: %v", codexOutputFiles)
t.Logf("Gemini engine declares: %v", geminiOutputFiles)
}

func TestEngineOutputCleanupExcludesTmpFiles(t *testing.T) {
Expand Down Expand Up @@ -612,3 +625,59 @@ This workflow tests that the redacted URLs log file is included in artifact uplo

t.Log("Successfully verified that redacted URLs log path is included in engine output collection")
}

func TestGeminiEngineOutputFilesGeneratedByCompiler(t *testing.T) {
// Create temporary directory for test files
tmpDir := testutil.TempDir(t, "gemini-engine-output-test")

testContent := `---
on: push
permissions:
contents: read
issues: read
pull-requests: read
tools:
github:
allowed: [list_issues]
engine: gemini
---

# Test Gemini Engine Output Collection

This workflow tests that the Gemini engine error log wildcard is uploaded as an artifact.
`

testFile := filepath.Join(tmpDir, "test-gemini-output.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile Gemini workflow: %v", err)
}

lockFile := stringutil.MarkdownToLockFile(testFile)
lockContent, err := os.ReadFile(lockFile)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
lockStr := string(lockContent)

// Verify that the compiler generates the Upload engine output files step
if !strings.Contains(lockStr, "- name: Upload engine output files") {
t.Error("Gemini workflow should have 'Upload engine output files' step generated by compiler")
}

// Verify that the Gemini error log wildcard path is included in the artifact upload
if !strings.Contains(lockStr, "/tmp/gemini-client-error-*.json") {
t.Error("Gemini workflow should include '/tmp/gemini-client-error-*.json' in engine output artifact upload")
}

// Verify that the artifact name is agent_outputs
if !strings.Contains(lockStr, "name: agent_outputs") {
t.Error("Gemini engine output artifact should use 'agent_outputs' name")
}

t.Log("Successfully verified that Gemini engine error log wildcard is included in engine output collection")
}
30 changes: 16 additions & 14 deletions pkg/workflow/gemini_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,14 @@ func (e *GeminiEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHub
return steps
}

// GetDeclaredOutputFiles returns the output files that Gemini may produce
// GetDeclaredOutputFiles returns the output files that Gemini may produce.
// Gemini CLI writes structured error reports to /tmp/gemini-client-error-*.json
// with a timestamp in the filename (e.g. gemini-client-error-Turn.run-sendMessageStream-2026-02-21T20-45-59-824Z.json).
// These files provide detailed diagnostics when the Gemini API call fails.
func (e *GeminiEngine) GetDeclaredOutputFiles() []string {
return []string{}
return []string{
"/tmp/gemini-client-error-*.json",
}
}

// GetExecutionSteps returns the GitHub Actions steps for executing Gemini
Expand All @@ -172,10 +177,10 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
// Build gemini CLI arguments based on configuration
var geminiArgs []string

// Model is always passed via the native GEMINI_MODEL environment variable when configured.
// Model is passed via the native GEMINI_MODEL environment variable only when explicitly
// configured. When not configured, the Gemini CLI uses its built-in default model.
// This avoids embedding the value directly in the shell command (which fails template injection
// validation for GitHub Actions expressions like ${{ inputs.model }}).
// Fallback for unconfigured model uses GH_AW_MODEL_AGENT_GEMINI with shell expansion.
modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != ""

// Gemini CLI reads MCP config from .gemini/settings.json (project-level)
Expand Down Expand Up @@ -236,6 +241,11 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
"GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}",
"GH_AW_PROMPT": "/tmp/gh-aw/aw-prompts/prompt.txt",
"GITHUB_WORKSPACE": "${{ github.workspace }}",
// Enable verbose debug logging from Gemini CLI for better diagnostics.
// Gemini CLI uses the npm 'debug' package, and 'gemini-cli:*' enables all
// internal Gemini CLI debug channels (see: https://gemini-cli-docs.pages.dev/cli/configuration).
// Non-JSON debug lines are gracefully skipped by ParseLogMetrics.
"DEBUG": "gemini-cli:*",
}

// Add MCP config env var if needed (points to .gemini/settings.json for Gemini)
Expand All @@ -252,22 +262,14 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
// Add safe outputs env
applySafeOutputEnvToMap(env, workflowData)

// Set the model environment variable.
// Set the model environment variable only when explicitly configured.
// When model is configured, use the native GEMINI_MODEL env var - the Gemini CLI reads it
// directly, avoiding the need to embed the value in the shell command (which would fail
// template injection validation for GitHub Actions expressions like ${{ inputs.model }}).
// When model is not configured, fall back to GH_AW_MODEL_AGENT/DETECTION_GEMINI so users
// can set a default via GitHub Actions variables.
// When model is not configured, let the Gemini CLI use its built-in default model.
if modelConfigured {
geminiLog.Printf("Setting %s env var for model: %s", constants.GeminiCLIModelEnvVar, workflowData.EngineConfig.Model)
env[constants.GeminiCLIModelEnvVar] = workflowData.EngineConfig.Model
} else {
isDetectionJob := workflowData.SafeOutputs == nil
if isDetectionJob {
env[constants.EnvVarModelDetectionGemini] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarModelDetectionGemini)
} else {
env[constants.EnvVarModelAgentGemini] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarModelAgentGemini)
}
}

// Generate the execution step
Expand Down
32 changes: 19 additions & 13 deletions pkg/workflow/gemini_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ func TestGeminiEngine(t *testing.T) {

t.Run("declared output files", func(t *testing.T) {
outputFiles := engine.GetDeclaredOutputFiles()
assert.Empty(t, outputFiles, "Should not declare any output files")
require.Len(t, outputFiles, 1, "Should declare one output file path")
assert.Equal(t, "/tmp/gemini-client-error-*.json", outputFiles[0], "Should declare Gemini error log wildcard path")
})
}

Expand Down Expand Up @@ -226,30 +227,35 @@ func TestGeminiEngineExecution(t *testing.T) {
assert.Contains(t, stepContent, "GEMINI_API_KEY:", "Should include GEMINI_API_KEY")
assert.Contains(t, stepContent, "GH_AW_PROMPT:", "Should include GH_AW_PROMPT")
assert.Contains(t, stepContent, "GITHUB_WORKSPACE:", "Should include GITHUB_WORKSPACE")
assert.Contains(t, stepContent, "DEBUG: gemini-cli:*", "Should include DEBUG env var for verbose diagnostics")
})

t.Run("model environment variables", func(t *testing.T) {
// Detection job (no SafeOutputs)
detectionWorkflow := &WorkflowData{
Name: "detection",
SafeOutputs: nil,
// When model is not configured, no model env var should be set (let Gemini CLI use its default)
noModelWorkflow := &WorkflowData{
Name: "no-model",
SafeOutputs: &SafeOutputsConfig{},
}

steps := engine.GetExecutionSteps(detectionWorkflow, "/tmp/test.log")
steps := engine.GetExecutionSteps(noModelWorkflow, "/tmp/test.log")
require.Len(t, steps, 1)
stepContent := strings.Join(steps[0], "\n")
assert.Contains(t, stepContent, "GH_AW_MODEL_DETECTION_GEMINI", "Should include detection model env var")
assert.NotContains(t, stepContent, "GH_AW_MODEL_DETECTION_GEMINI", "Should not include detection model env var when model is unconfigured")
assert.NotContains(t, stepContent, "GH_AW_MODEL_AGENT_GEMINI", "Should not include agent model env var when model is unconfigured")
assert.NotContains(t, stepContent, "GEMINI_MODEL", "Should not include GEMINI_MODEL when model is unconfigured")

// Agent job (with SafeOutputs)
agentWorkflow := &WorkflowData{
Name: "agent",
SafeOutputs: &SafeOutputsConfig{},
// When model is configured, use the native GEMINI_MODEL env var
modelWorkflow := &WorkflowData{
Name: "model-configured",
EngineConfig: &EngineConfig{
Model: "gemini-2.0-flash",
},
}

steps = engine.GetExecutionSteps(agentWorkflow, "/tmp/test.log")
steps = engine.GetExecutionSteps(modelWorkflow, "/tmp/test.log")
require.Len(t, steps, 1)
stepContent = strings.Join(steps[0], "\n")
assert.Contains(t, stepContent, "GH_AW_MODEL_AGENT_GEMINI", "Should include agent model env var")
assert.Contains(t, stepContent, "GEMINI_MODEL: gemini-2.0-flash", "Should set GEMINI_MODEL when model is explicitly configured")
})
}

Expand Down
23 changes: 22 additions & 1 deletion pkg/workflow/step_order_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ func (t *StepOrderTracker) findUnscannablePaths(artifactUploads []StepRecord) []
}

// isPathScannedBySecretRedaction checks if a path would be scanned by the secret redaction step
// or is otherwise safe to upload (known engine-controlled diagnostic paths).
func isPathScannedBySecretRedaction(path string) bool {
// Paths must be under /tmp/gh-aw/ or /opt/gh-aw/ to be scanned
// Accept both literal paths and environment variable references
Expand All @@ -184,7 +185,27 @@ func isPathScannedBySecretRedaction(path string) bool {
// For now, we'll allow ${{ env.* }} patterns through as we can't resolve them at compile time
// Assume environment variables that might contain /tmp/gh-aw or /opt/gh-aw paths are safe
// This is a conservative assumption - in practice these are controlled by the compiler
return strings.Contains(path, "${{ env.")
if strings.Contains(path, "${{ env.") {
return true
}

// Allow wildcard paths under /tmp/ with a known-safe extension.
// These are engine-declared diagnostic output files (e.g. Gemini CLI error reports at
// /tmp/gemini-client-error-*.json). They are produced by the CLI tool itself, not by
// agent-generated content, and they live outside /tmp/gh-aw/ so they are not scanned by
// the redact_secrets step. However, these files (JSON error reports, log files) are
// structurally unlikely to contain raw secret values, so we allow them through validation.
if strings.HasPrefix(path, "/tmp/") && strings.Contains(path, "*") {
ext := filepath.Ext(path)
safeExtensions := []string{".txt", ".json", ".log", ".jsonl"}
for _, safeExt := range safeExtensions {
if ext == safeExt {
return true
}
}
}
Comment on lines +192 to +206
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new wildcard path validation logic added to isPathScannedBySecretRedaction (lines 192-206) lacks test coverage. Consider adding test cases to step_order_validation_test.go to verify that:

  1. Wildcard paths under /tmp/ with safe extensions (.json, .log, .txt, .jsonl) are correctly identified as safe (e.g., /tmp/gemini-client-error-*.json)
  2. Wildcard paths with unsafe extensions are rejected (e.g., /tmp/data-*.sh)
  3. Wildcard paths outside /tmp/ are rejected (e.g., /var/log/error-*.json)

Copilot uses AI. Check for mistakes.

return false
}

// Path must have one of the scanned extensions: .txt, .json, .log, .jsonl
Expand Down
Loading