From 93290e61c305fc12c45f564428b69093275e6fdb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:20:42 +0000 Subject: [PATCH 1/7] Initial plan From 240efd5bb4cf7707223481daf00956e9b4a84b81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:36:31 +0000 Subject: [PATCH 2/7] initial: plan for P1 Antigravity/Gemini engine consolidation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentic-auto-upgrade.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index e169cea32b6..7035101ee35 100644 --- a/.github/workflows/agentic-auto-upgrade.yml +++ b/.github/workflows/agentic-auto-upgrade.yml @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade on: schedule: - - cron: "11 4 * * 6" # Weekly (auto-upgrade) + - cron: "21 3 * * 5" # Weekly (auto-upgrade) workflow_dispatch: permissions: From 2cbbdb72431728f2ce0ba264219533d62be49fbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:55:23 +0000 Subject: [PATCH 3/7] refactor(P1): introduce googleCLIEngine shared base for Gemini/Antigravity engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1.1: computeGeminiToolsCore now delegates to computeGoogleCLIToolsCore via appendBashTools (closes active divergence; previously used inline two-pass logic) - P1.2: new google_cli_engine.go contains: * googleCLIEngineConfig – all per-engine constants in one place * googleCLIEngine – shared base embedding BaseEngine * appendBashTools / computeGoogleCLIToolsCore – shared utility functions Shared methods on googleCLIEngine (promoted to both engines via embedding): GetModelEnvVarName, GetSupportedEnvVarKeys, GetRequiredSecretNames, GetSecretValidationStep, GetDeclaredOutputFiles, GetAgentManifestFiles, GetAgentManifestPathPrefixes, GetPreBundleSteps, RenderMCPConfig, ParseLogMetrics, GetLogParserScriptId, generateSettingsStep, GetExecutionSteps - gemini_engine.go: now only NewGeminiEngine + GetInstallationSteps - antigravity_engine.go: now only NewAntigravityEngine + GetInstallationSteps - gemini_tools.go / antigravity_tools.go: thin wrappers for tests - Removed gemini_mcp.go, antigravity_mcp.go, gemini_logs.go, antigravity_logs.go - Updated test calls from generateGeminiSettingsStep / generateAntigravitySettingsStep to the unified generateSettingsStep Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/antigravity_engine.go | 387 +++------------ pkg/workflow/antigravity_engine_test.go | 16 +- pkg/workflow/antigravity_logs.go | 19 - pkg/workflow/antigravity_mcp.go | 18 - pkg/workflow/antigravity_tools.go | 170 +------ pkg/workflow/gemini_engine.go | 386 +++------------ pkg/workflow/gemini_engine_test.go | 16 +- pkg/workflow/gemini_logs.go | 19 - pkg/workflow/gemini_mcp.go | 18 - pkg/workflow/gemini_tools.go | 164 +------ pkg/workflow/google_cli_engine.go | 598 ++++++++++++++++++++++++ 11 files changed, 735 insertions(+), 1076 deletions(-) delete mode 100644 pkg/workflow/antigravity_logs.go delete mode 100644 pkg/workflow/antigravity_mcp.go delete mode 100644 pkg/workflow/gemini_logs.go delete mode 100644 pkg/workflow/gemini_mcp.go create mode 100644 pkg/workflow/google_cli_engine.go diff --git a/pkg/workflow/antigravity_engine.go b/pkg/workflow/antigravity_engine.go index 2efc4232708..6a4a912d053 100644 --- a/pkg/workflow/antigravity_engine.go +++ b/pkg/workflow/antigravity_engine.go @@ -1,102 +1,83 @@ package workflow import ( - "fmt" - "maps" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/workflow/compilerenv" ) -var antigravityLog = logger.New("workflow:antigravity_engine") - -// AntigravityEngine represents the Google Antigravity CLI agentic engine +// AntigravityEngine represents the Google Antigravity CLI agentic engine. +// It embeds googleCLIEngine for all shared behavior; only GetInstallationSteps +// is defined here since Antigravity installs from a GCS binary (not npm). type AntigravityEngine struct { - BaseEngine + googleCLIEngine } var _ CodingAgentEngine = (*AntigravityEngine)(nil) func NewAntigravityEngine() *AntigravityEngine { return &AntigravityEngine{ - BaseEngine: BaseEngine{ - id: "antigravity", - displayName: "Antigravity CLI", - description: "Antigravity CLI with headless mode and LLM gateway support", - experimental: true, - ghSkillAgentName: "antigravity", - capabilities: EngineCapabilities{ - ToolsAllowlist: true, - MaxTurns: true, - MaxContinuations: false, // Antigravity CLI does not support --max-autopilot-continues-style continuation mode - WebSearch: false, - NativeAgentFile: false, // Antigravity does not support agent file natively; the compiler prepends the agent file content to prompt.txt + googleCLIEngine: googleCLIEngine{ + BaseEngine: BaseEngine{ + id: "antigravity", + displayName: "Antigravity CLI", + description: "Antigravity CLI with headless mode and LLM gateway support", + experimental: true, + ghSkillAgentName: "antigravity", + capabilities: EngineCapabilities{ + ToolsAllowlist: true, + MaxTurns: true, + MaxContinuations: false, // Antigravity CLI does not support --max-autopilot-continues-style continuation mode + WebSearch: false, + NativeAgentFile: false, // Antigravity does not support agent file natively; the compiler prepends the agent file content to prompt.txt + }, + dedicatedLLMGatewayPort: constants.AntigravityLLMGatewayPort, + }, + cfg: googleCLIEngineConfig{ + log: logger.New("workflow:antigravity_engine"), + apiKeySecretName: constants.AntigravityAPIKey, + apiBaseURLEnvVar: "ANTIGRAVITY_API_BASE_URL", + modelEnvVar: constants.AntigravityCLIModelEnvVar, + trustWorkspaceEnvVar: "ANTIGRAVITY_CLI_TRUST_WORKSPACE", + debugEnvValue: "antigravity-cli:*", + defaultCLIBinary: "agy", + // Grant broad tool permission inside the workflow sandbox without blocking on + // permission prompts. agy does not support the Gemini-style --yolo/--skip-trust + // flags; --dangerously-skip-permissions is the equivalent for Antigravity. + cliArgs: []string{"--dangerously-skip-permissions"}, + configDir: ".antigravity", + baseConfigEnvVar: "GH_AW_ANTIGRAVITY_BASE_CONFIG", + secretValidationURL: "https://antigravity.google/docs/cli-overview", + secretValidationLabel: "Antigravity CLI", + configStepName: "Write Antigravity Config", + executionStepName: "Execute Antigravity CLI", + errorMoveStepName: "Move Antigravity error files to artifact directory", + errorFileSrcGlob: "/tmp/antigravity-client-error-*.json", + errorFileDstGlob: constants.TmpAntigravityClientErrorGlob, + agentManifestFiles: []string{"ANTIGRAVITY.md", "AGENTS.md"}, + agentManifestPrefixes: []string{".antigravity/"}, + logParserScriptID: "parse_antigravity_log", + logParserEngineName: "Antigravity", + // Exclude both the Antigravity and mirrored Gemini API keys from the sandbox env. + excludeAPIKeys: []string{constants.AntigravityAPIKey, constants.GeminiAPIKey}, + // GEMINI_API_KEY must be allowed through FilterEnvForSecrets because it is + // mirrored from ANTIGRAVITY_API_KEY and is required by the Gemini proxy sidecar. + extraAllowedSecrets: []string{constants.GeminiAPIKey}, + // Mirror ANTIGRAVITY_API_KEY → GEMINI_API_KEY so the Gemini proxy sidecar can + // authenticate without requiring users to duplicate secrets. + mirrorAPIKeyAs: constants.GeminiAPIKey, }, - dedicatedLLMGatewayPort: constants.AntigravityLLMGatewayPort, }, } } -// GetModelEnvVarName returns the native environment variable name that the Antigravity CLI uses -// for model selection. Setting ANTIGRAVITY_MODEL is equivalent to passing --model to the CLI. -func (e *AntigravityEngine) GetModelEnvVarName() string { - return constants.AntigravityCLIModelEnvVar -} - -// GetRequiredSecretNames returns the list of secrets required by the Antigravity engine -// This includes ANTIGRAVITY_API_KEY and optionally MCP_GATEWAY_API_KEY, GITHUB_MCP_SERVER_TOKEN, -// HTTP MCP header secrets, and mcp-scripts secrets -func (e *AntigravityEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { - antigravityLog.Print("Collecting required secrets for Antigravity engine") - secrets := []string{"ANTIGRAVITY_API_KEY"} - - // Add common MCP secrets (MCP_GATEWAY_API_KEY if MCP servers present, mcp-scripts secrets) - secrets = append(secrets, collectCommonMCPSecrets(workflowData)...) - - // Add GitHub token for GitHub MCP server if present - if hasGitHubTool(workflowData.ParsedTools) { - antigravityLog.Print("Adding GITHUB_MCP_SERVER_TOKEN secret") - secrets = append(secrets, "GITHUB_MCP_SERVER_TOKEN") - } - - // Add HTTP MCP header secret names - headerSecrets := collectHTTPMCPHeaderSecrets(workflowData.Tools) - for varName := range headerSecrets { - secrets = append(secrets, varName) - } - if len(headerSecrets) > 0 { - antigravityLog.Printf("Added %d HTTP MCP header secrets", len(headerSecrets)) - } - - return secrets -} - -// GetSupportedEnvVarKeys returns the engine.env variable names that the Antigravity engine -// supports as defined in the AWF specification. -func (e *AntigravityEngine) GetSupportedEnvVarKeys() []string { - return []string{ - constants.AntigravityAPIKey, - } -} - -// GetSecretValidationStep returns the secret validation step for the Antigravity engine. -// Returns an empty step if custom command is specified. -func (e *AntigravityEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { - return BuildDefaultSecretValidationStep( - workflowData, - []string{"ANTIGRAVITY_API_KEY"}, - "Antigravity CLI", - "https://antigravity.google/docs/cli-overview", - ) -} - +// GetInstallationSteps returns the GitHub Actions steps needed to install Antigravity CLI. +// Antigravity installs from a GCS binary via install_antigravity_cli.sh; Gemini uses npm. func (e *AntigravityEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { - antigravityLog.Printf("Generating installation steps for Antigravity engine: workflow=%s", workflowData.Name) + e.cfg.log.Printf("Generating installation steps for Antigravity engine: workflow=%s", workflowData.Name) // Skip installation if custom command is specified if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - antigravityLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) + e.cfg.log.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) return []GitHubActionStep{} } @@ -107,257 +88,3 @@ func (e *AntigravityEngine) GetInstallationSteps(workflowData *WorkflowData) []G installSteps := GenerateAntigravityInstallerSteps(version, "Install Antigravity CLI") return BuildNpmEngineInstallStepsWithAWF(installSteps, workflowData) } - -// GetDeclaredOutputFiles returns the output files that Antigravity may produce. -// Antigravity CLI writes structured error reports to /tmp/antigravity-client-error-*.json -// with a timestamp in the filename (e.g. antigravity-client-error-Turn.run-sendMessageStream-2026-02-21T20-45-59-824Z.json). -// These files provide detailed diagnostics when the Antigravity API call fails. -// GetPreBundleSteps moves these files into /tmp/gh-aw/ so all artifact paths share a common -// ancestor under /tmp/gh-aw/ and the actions/upload-artifact LCA calculation stays correct. -func (e *AntigravityEngine) GetDeclaredOutputFiles() []string { - return []string{ - constants.TmpAntigravityClientErrorGlob, - } -} - -// GetAgentManifestFiles returns Antigravity-specific instruction files that should be -// treated as security-sensitive manifests. A fork PR that modifies these files -// can redirect the agent's behaviour or expand which files it treats as instructions. -// ANTIGRAVITY.md is the primary per-project context file; AGENTS.md is the cross-engine -// convention that Antigravity CLI also reads. -func (e *AntigravityEngine) GetAgentManifestFiles() []string { - return []string{"ANTIGRAVITY.md", "AGENTS.md"} -} - -// GetAgentManifestPathPrefixes returns Antigravity-specific config directory prefixes. -// The .antigravity/ directory contains settings.json and other configuration that could -// expand which files are treated as instructions or alter agent behaviour. -// Protecting this directory prevents fork PRs from injecting malicious configuration. -func (e *AntigravityEngine) GetAgentManifestPathPrefixes() []string { - return []string{".antigravity/"} -} - -// GetPreBundleSteps returns a step that moves Antigravity CLI error reports from /tmp/ into -// /tmp/gh-aw/ before the unified artifact upload. This keeps all artifact paths under -// /tmp/gh-aw/ so that actions/upload-artifact computes the correct least-common-ancestor -// path and downstream jobs find files at the expected locations. -func (e *AntigravityEngine) GetPreBundleSteps(workflowData *WorkflowData) []GitHubActionStep { - return []GitHubActionStep{ - { - " - name: Move Antigravity error files to artifact directory", - " if: always()", - " run: mv /tmp/antigravity-client-error-*.json /tmp/gh-aw/ 2>/dev/null || true", - }, - } -} - -// GetExecutionSteps returns the GitHub Actions steps for executing Antigravity -func (e *AntigravityEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep { - antigravityLog.Printf("Generating execution steps for Antigravity engine: workflow=%s, firewall=%v", workflowData.Name, isFirewallEnabled(workflowData)) - - var steps []GitHubActionStep - - // Write .antigravity/settings.json with context.includeDirectories and tools.core. - // This step runs after the MCP gateway setup (which may have written mcpServers config) - // and merges the context/tools settings into any existing settings.json. - settingsStep := e.generateAntigravitySettingsStep(workflowData) - steps = append(steps, settingsStep) - - // Build agy CLI arguments based on configuration - var agyArgs []string - - // Model is passed via the native ANTIGRAVITY_MODEL environment variable only when explicitly - // configured. When not configured, the Antigravity 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 }}). - modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" - - // Antigravity CLI reads MCP config from .antigravity/settings.json (project-level) - // The conversion script (convert_gateway_config_antigravity.sh) writes settings.json - // during the MCP setup step, so no --mcp-config flag is needed here. - - // Auto-approve all tool executions so non-interactive CI runs don't block on permission prompts. - // agy does not support the Gemini-style --yolo/--skip-trust flags. - // This flag grants broad tool permission inside the workflow sandbox, so it is only used in AWF-managed runs. - agyArgs = append(agyArgs, "--dangerously-skip-permissions") - - // Note: the --prompt argument is appended raw after shellJoinArgs below because it contains - // a shell command substitution ("$(cat ...)") that must NOT go through shellEscapeArg — - // single-quoting it would prevent shell expansion at runtime. - - // Build the command - commandName := "agy" - if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - commandName = workflowData.EngineConfig.Command - } - - // Append the prompt arg raw (not through shellJoinArgs) to preserve shell expansion - agyCommand := fmt.Sprintf(`%s %s --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, commandName, shellJoinArgs(agyArgs)) - agyCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + agyCommand - - // Build the full command with AWF wrapping if enabled - var command string - firewallEnabled := isFirewallEnabled(workflowData) - if firewallEnabled { - // Get allowed domains: prefer the pre-warmed cache on WorkflowData to avoid - // re-running the expensive map+sort operation. - var allowedDomains string - if workflowData.CachedAllowedDomainsComputed { - allowedDomains = workflowData.CachedAllowedDomainsStr - } else { - allowedDomains = GetAllowedDomainsForEngine(constants.AntigravityEngine, - workflowData.NetworkPermissions, - workflowData.Tools, - workflowData.Runtimes, - ) - } - // Add GHES/custom API target domains to the firewall allow-list when engine.api-target is set - if workflowData.EngineConfig != nil && workflowData.EngineConfig.APITarget != "" { - allowedDomains = mergeAPITargetDomains(allowedDomains, workflowData.EngineConfig.APITarget) - } - - npmPathSetup := GetNpmBinPathSetup() - agyCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, agyCommand) - // Add MCP CLI bin directory to PATH when cli-proxy is enabled - if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { - agyCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, agyCommandWithPath) - } - - command = BuildAWFCommand(AWFCommandConfig{ - EngineName: "antigravity", - EngineCommand: agyCommandWithPath, - LogFile: logFile, - WorkflowData: workflowData, - UsesTTY: false, - AllowedDomains: allowedDomains, - // Create the agent step summary file before AWF starts so it is accessible - // inside the sandbox. The agent writes its step summary content here, and the - // file is appended to $GITHUB_STEP_SUMMARY after secret redaction. - PathSetup: "touch " + AgentStepSummaryPath, - // Exclude every env var whose step-env value is a secret so the agent - // cannot read raw token values via bash tools (env / printenv). - ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"ANTIGRAVITY_API_KEY", "GEMINI_API_KEY"}), - }) - } else { - command = fmt.Sprintf(`set -o pipefail -printf '%%s' "$(date +%%s%%3N)" > %s -touch %s -(umask 177 && touch %s) -%s 2>&1 | tee -a %s`, AgentCLIStartMsPath, AgentStepSummaryPath, logFile, agyCommand, logFile) - } - - // Build environment variables - env := map[string]string{ - "ANTIGRAVITY_API_KEY": "${{ secrets.ANTIGRAVITY_API_KEY }}", - "GH_AW_PROMPT": constants.AwPromptsFile, - // Tag the step as a GitHub AW agentic execution for discoverability by agents - "GITHUB_AW": "true", - "GITHUB_WORKSPACE": "${{ github.workspace }}", - "RUNNER_TEMP": "${{ runner.temp }}", - // Override GITHUB_STEP_SUMMARY with a path that exists inside the sandbox. - // The runner's original path is unreachable within the AWF isolated filesystem; - // we create this file before the agent starts and append it to the real - // $GITHUB_STEP_SUMMARY after secret redaction. - "GITHUB_STEP_SUMMARY": AgentStepSummaryPath, - // Enable verbose debug logging from Antigravity CLI for better diagnostics. - // Antigravity CLI uses the npm 'debug' package, and 'antigravity-cli:*' enables all - // internal Antigravity CLI debug channels (see: https://antigravity.google/docs/cli-overview). - // Non-JSON debug lines are gracefully skipped by ParseLogMetrics. - "DEBUG": "antigravity-cli:*", - // Trust the workspace to prevent Antigravity CLI v1.x from overriding --yolo to default - // approval mode when the workspace is untrusted, which causes exit code 55. - "ANTIGRAVITY_CLI_TRUST_WORKSPACE": "true", - } - injectWorkflowCallNetworkAllowedEnv(env, workflowData) - // Indicate the phase: "agent" for the main run, "detection" for threat detection - // Include the compiler version so agents can identify which gh-aw version generated the workflow - if workflowData.IsDetectionRun { - env["GH_AW_PHASE"] = "detection" - } else { - env["GH_AW_PHASE"] = "agent" - } - if IsRelease() { - env["GH_AW_VERSION"] = GetVersion() - } else { - env["GH_AW_VERSION"] = "dev" - } - - // Add MCP config env var if needed (points to .antigravity/settings.json for Antigravity) - if HasMCPServers(workflowData) { - env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/.antigravity/settings.json" - } - - // When the firewall (AWF) is enabled with --enable-api-proxy, point Antigravity CLI at the - // LLM gateway sidecar instead of the real googleapis.com endpoint. - if firewallEnabled { - env["ANTIGRAVITY_API_BASE_URL"] = fmt.Sprintf("http://host.docker.internal:%d", constants.AntigravityLLMGatewayPort) - - // Set git identity environment variables so the first git commit succeeds inside the - // container. AWF's --env-all forwards these to the container, ensuring git does not - // rely on the host-side ~/.gitconfig which is not visible in the sandbox. - maps.Copy(env, getGitIdentityEnvVars()) - } - - // Add safe outputs env - applySafeOutputEnvToMap(env, workflowData) - - // Propagate W3C trace context so engine spans nest under the gh-aw.agent.setup span. - applyTraceContextEnvToMap(env) - - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" { - env["GH_AW_MAX_TURNS"] = workflowData.EngineConfig.MaxTurns - } else { - env["GH_AW_MAX_TURNS"] = compilerenv.BuildDefaultMaxTurnsExpression() - } - - // Set the model environment variable only when explicitly configured. - // When model is configured, use the native ANTIGRAVITY_MODEL env var - the Antigravity 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, let the Antigravity CLI use its built-in default model. - if modelConfigured { - antigravityLog.Printf("Setting %s env var for model: %s", constants.AntigravityCLIModelEnvVar, workflowData.EngineConfig.Model) - env[constants.AntigravityCLIModelEnvVar] = workflowData.EngineConfig.Model - } - - // Add custom environment variables from engine config. - // This allows users to override the default engine token expression (e.g. - // ANTIGRAVITY_API_KEY: ${{ secrets.MY_ORG_ANTIGRAVITY_KEY }}) via engine.env. - applyEngineCwdEnv(env, workflowData) - if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { - maps.Copy(env, workflowData.EngineConfig.Env) - } - - // Add custom environment variables from agent config - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil && len(agentConfig.Env) > 0 { - maps.Copy(env, agentConfig.Env) - antigravityLog.Printf("Added %d custom env vars from agent config", len(agentConfig.Env)) - } - // The Antigravity CLI and AWF's Gemini API proxy both rely on a Gemini provider key. - // Keep GEMINI_API_KEY aligned with the effective ANTIGRAVITY_API_KEY by default so the - // workflow can authenticate non-interactively without requiring users to duplicate secrets. - if _, hasGeminiKey := env["GEMINI_API_KEY"]; !hasGeminiKey { - env["GEMINI_API_KEY"] = env["ANTIGRAVITY_API_KEY"] - } - - // Generate the execution step - stepLines := []string{ - " - name: Execute Antigravity CLI", - " id: agentic_execution", - } - - // Filter environment variables for security - allowedSecrets := append([]string{"GEMINI_API_KEY"}, e.GetRequiredSecretNames(workflowData)...) - filteredEnv := FilterEnvForSecrets(env, allowedSecrets) - - // Inject GH_TOKEN for CLI proxy (added after filtering since it uses a special - // fallback expression that is always allowed when cli-proxy is enabled) - addCliProxyGHTokenToEnv(filteredEnv, workflowData) - - // Format step with command and env - stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) - - steps = append(steps, GitHubActionStep(stepLines)) - return steps -} diff --git a/pkg/workflow/antigravity_engine_test.go b/pkg/workflow/antigravity_engine_test.go index c364e1d2f1a..9077fb9a0b0 100644 --- a/pkg/workflow/antigravity_engine_test.go +++ b/pkg/workflow/antigravity_engine_test.go @@ -497,7 +497,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "Write Antigravity Config", "Should have correct step name") @@ -512,7 +512,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "if [ -f", "Should check for existing settings.json") @@ -527,7 +527,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { "bash": []any{"grep", "git"}, }, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "run_shell_command(grep)", "Should include run_shell_command(grep) for bash grep") @@ -542,7 +542,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { "edit": map[string]any{}, }, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "write_file", "Should include write_file for edit tool") @@ -554,7 +554,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") // The JSON value must be single-quoted so YAML doesn't treat it as an object @@ -568,7 +568,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { "web-fetch": nil, }, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "web_fetch", "Should include web_fetch in tools.core when web-fetch is specified") @@ -579,7 +579,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.NotContains(t, content, "web_fetch", "Should not include web_fetch in tools.core when web-fetch is not specified") @@ -601,7 +601,7 @@ func TestGenerateAntigravitySettingsStep(t *testing.T) { NoOp: &NoOpConfig{}, }, } - step := engine.generateAntigravitySettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "run_shell_command(echo)", "Should include original restricted bash command") diff --git a/pkg/workflow/antigravity_logs.go b/pkg/workflow/antigravity_logs.go deleted file mode 100644 index f7944a2dec7..00000000000 --- a/pkg/workflow/antigravity_logs.go +++ /dev/null @@ -1,19 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var antigravityLogsLog = logger.New("workflow:antigravity_logs") - -// ParseLogMetrics parses Antigravity CLI log output and extracts metrics. -// Antigravity CLI outputs a single JSON response when using --output-format json. -// We parse the last valid JSON line (most complete response) and aggregate stats. -func (e *AntigravityEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { - return parseStatsJSONLMetrics(logContent, verbose, "Antigravity", antigravityLogsLog) -} - -// GetLogParserScriptId returns the script ID for parsing Antigravity logs -func (e *AntigravityEngine) GetLogParserScriptId() string { - return "parse_antigravity_log" -} diff --git a/pkg/workflow/antigravity_mcp.go b/pkg/workflow/antigravity_mcp.go deleted file mode 100644 index d1e9ef5036c..00000000000 --- a/pkg/workflow/antigravity_mcp.go +++ /dev/null @@ -1,18 +0,0 @@ -package workflow - -import ( - "strings" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" -) - -var antigravityMCPLog = logger.New("workflow:antigravity_mcp") - -// RenderMCPConfig renders MCP server configuration for Antigravity CLI -func (e *AntigravityEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error { - antigravityMCPLog.Printf("Rendering MCP config for Antigravity: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools)) - - // Antigravity uses JSON format without Copilot-specific fields and multi-line args - return renderDefaultJSONMCPConfig(yaml, tools, mcpTools, workflowData, constants.ShellMcpServersJsonPath) -} diff --git a/pkg/workflow/antigravity_tools.go b/pkg/workflow/antigravity_tools.go index 787882bcff2..65171a93c70 100644 --- a/pkg/workflow/antigravity_tools.go +++ b/pkg/workflow/antigravity_tools.go @@ -2,27 +2,14 @@ package workflow // This file provides Antigravity engine tool configuration logic. // -// It handles two key responsibilities: +// computeAntigravityToolsCore is a thin wrapper around computeGoogleCLIToolsCore +// that preserves the existing function signature for tests and callers while +// delegating all logic to the shared implementation. // -// 1. Tool Core Mapping (computeAntigravityToolsCore): -// Converts neutral tool names from the workflow configuration into -// Antigravity CLI built-in tool names for the tools.core allowlist in -// .antigravity/settings.json. This restricts the agent to only the tools -// explicitly requested by the workflow. -// -// 2. Settings Step Generation (generateAntigravitySettingsStep): -// Generates a GitHub Actions step that writes or merges .antigravity/settings.json -// before the Antigravity CLI execution. This step always sets: -// - context.includeDirectories: ["/tmp/"] so file tools can access /tmp/ -// - tools.core: derived from neutral tool configuration -// The merge approach ensures MCP server config (written by convert_gateway_config_antigravity.sh) -// is preserved while adding the context and tool settings. +// The settings-step generator (generateSettingsStep) lives on googleCLIEngine and +// calls computeGoogleCLIToolsCore directly with the engine's configured logger. import ( - "encoding/json" - "fmt" - "sort" - "github.com/github/gh-aw/pkg/logger" ) @@ -41,150 +28,5 @@ var antigravityToolsLog = logger.New("workflow:antigravity_tools") // // See: https://antigravity.google/docs/cli-overview func computeAntigravityToolsCore(tools map[string]any) []string { - // Always include essential read-only file system tools - toolsCore := []string{ - "glob", - "grep_search", - "list_directory", - "read_file", - "read_many_files", - } - - if tools == nil { - return toolsCore - } - - // Map bash neutral tool to run_shell_command - if bashConfig, hasBash := tools["bash"]; hasBash { - toolsCore = appendBashTools(toolsCore, bashConfig) - } - - // Map edit neutral tool to write_file and replace (Antigravity's file write tools) - if _, hasEdit := tools["edit"]; hasEdit { - antigravityToolsLog.Print("edit → replace, write_file") - toolsCore = append(toolsCore, "replace") - toolsCore = append(toolsCore, "write_file") - } - - // Map web-fetch neutral tool to web_fetch (Antigravity's native HTTP fetch tool) - // See: https://antigravity.google/docs/cli-overview - if _, hasWebFetch := tools["web-fetch"]; hasWebFetch { - antigravityToolsLog.Print("web-fetch → web_fetch") - toolsCore = append(toolsCore, "web_fetch") - } - - sort.Strings(toolsCore) - return toolsCore -} - -// appendBashTools maps the bash neutral tool configuration to run_shell_command -// entries and appends them to toolsCore. -func appendBashTools(toolsCore []string, bashConfig any) []string { - bashCommands, ok := bashConfig.([]any) - if !ok || len(bashCommands) == 0 { - // bash with no specific commands - allow all shell commands - antigravityToolsLog.Print("bash (no specific commands) → run_shell_command") - return append(toolsCore, "run_shell_command") - } - - // Single pass over bashCommands. A separate accumulator (specific) collects - // per-command entries so that if a wildcard ("*" or ":*") is found anywhere - // in the list — even after specific commands — only "run_shell_command" is - // appended and the pre-wildcard entries are discarded. This preserves the - // semantics of "any wildcard means allow all shell commands" regardless of - // command ordering. - var specific []string - for _, cmd := range bashCommands { - cmdStr, ok := cmd.(string) - if !ok { - continue - } - if cmdStr == "*" || cmdStr == ":*" { - antigravityToolsLog.Print("bash wildcard → run_shell_command") - return append(toolsCore, "run_shell_command") - } - // Normalize trailing " *" wildcard (e.g. "jq *" → "jq") so that - // all engines emit the canonical prefix form (run_shell_command(jq)) - // regardless of whether the command was written with or without the wildcard. - normalized, _ := normalizeBashCommand(cmdStr) - entry := fmt.Sprintf("run_shell_command(%s)", normalized) - antigravityToolsLog.Printf("bash %q → %s", cmdStr, entry) - specific = append(specific, entry) - } - return append(toolsCore, specific...) -} - -// generateAntigravitySettingsStep creates a GitHub Actions step that writes the -// Antigravity CLI project settings file (.antigravity/settings.json) before execution. -// -// This step: -// 1. Sets context.includeDirectories to ["/tmp/"] so that Antigravity CLI file system -// tools (write_file, replace) can access files in /tmp/ including -// /tmp/gh-aw/cache-memory/ and other agent working directories. -// 2. Sets tools.core to the list of built-in tools derived from the workflow's -// neutral tool configuration (bash → run_shell_command, edit → write_file/replace). -// 3. Merges the above settings with any existing .antigravity/settings.json, which -// may have been written by convert_gateway_config_antigravity.sh with MCP server -// configuration. The merge preserves the MCP server config while adding -// the context and tools settings. -func (e *AntigravityEngine) generateAntigravitySettingsStep(workflowData *WorkflowData) GitHubActionStep { - antigravityToolsLog.Printf("Generating Antigravity settings step for: %s", workflowData.Name) - - tools := workflowData.Tools - if tools == nil { - tools = make(map[string]any) - } - workflowDataWithEffectiveTools := *workflowData - workflowDataWithEffectiveTools.Tools = tools - tools = withMountedCLIShellCommandsInRestrictedBash(&workflowDataWithEffectiveTools) - - // Compute tools.core from neutral tool configuration - toolsCore := computeAntigravityToolsCore(tools) - antigravityToolsLog.Printf("tools.core entries: %d", len(toolsCore)) - - // Build the settings JSON object - config := map[string]any{ - "context": map[string]any{ - "includeDirectories": []string{"/tmp/"}, - }, - "tools": map[string]any{ - "core": toolsCore, - }, - } - - configJSON, err := json.Marshal(config) - if err != nil { - antigravityToolsLog.Printf("ERROR: Failed to marshal Antigravity settings: %v", err) - configJSON = []byte(`{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":[]}}`) - } - - // Generate a shell script that: - // - Creates the .antigravity directory if needed - // - Merges settings into an existing settings.json (from MCP gateway setup), or - // - Creates a new settings.json when no MCP servers are configured - // - // The JSON config is passed via the GH_AW_ANTIGRAVITY_BASE_CONFIG environment variable - // to avoid any shell quoting issues with special characters in the JSON. - // - // jq merge: '$existing * $base' means the RIGHT operand ($base) overrides the LEFT - // operand ($existing) for conflicting keys. Non-conflicting keys from $existing - // (e.g. mcpServers written by convert_gateway_config_antigravity.sh) are preserved. - command := `mkdir -p "$GITHUB_WORKSPACE/.antigravity" -SETTINGS="$GITHUB_WORKSPACE/.antigravity/settings.json" -BASE_CONFIG="$GH_AW_ANTIGRAVITY_BASE_CONFIG" -if [ -f "$SETTINGS" ]; then - MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') - echo "$MERGED" > "$SETTINGS" -else - echo "$BASE_CONFIG" > "$SETTINGS" -fi` - - stepLines := []string{ - " - name: Write Antigravity Config", - } - env := map[string]string{ - "GH_AW_ANTIGRAVITY_BASE_CONFIG": string(configJSON), - } - stepLines = FormatStepWithCommandAndEnv(stepLines, command, env) - return GitHubActionStep(stepLines) + return computeGoogleCLIToolsCore(tools, antigravityToolsLog) } diff --git a/pkg/workflow/gemini_engine.go b/pkg/workflow/gemini_engine.go index ff18878b950..6d5137ee15b 100644 --- a/pkg/workflow/gemini_engine.go +++ b/pkg/workflow/gemini_engine.go @@ -1,102 +1,78 @@ package workflow import ( - "fmt" - "maps" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/workflow/compilerenv" ) -var geminiLog = logger.New("workflow:gemini_engine") - -// GeminiEngine represents the Google Gemini CLI agentic engine +// GeminiEngine represents the Google Gemini CLI agentic engine. +// It embeds googleCLIEngine for all shared behavior; only GetInstallationSteps +// is defined here since Gemini installs via npm (@google/gemini-cli). type GeminiEngine struct { - BaseEngine + googleCLIEngine } var _ CodingAgentEngine = (*GeminiEngine)(nil) func NewGeminiEngine() *GeminiEngine { return &GeminiEngine{ - BaseEngine: BaseEngine{ - id: "gemini", - displayName: "Google Gemini CLI", - description: "Google Gemini CLI with headless mode and LLM gateway support", - experimental: false, - ghSkillAgentName: "gemini-cli", - capabilities: EngineCapabilities{ - ToolsAllowlist: true, - MaxTurns: true, - MaxContinuations: false, // Gemini CLI does not support --max-autopilot-continues-style continuation mode - WebSearch: false, - NativeAgentFile: false, // Gemini does not support agent file natively; the compiler prepends the agent file content to prompt.txt + googleCLIEngine: googleCLIEngine{ + BaseEngine: BaseEngine{ + id: "gemini", + displayName: "Google Gemini CLI", + description: "Google Gemini CLI with headless mode and LLM gateway support", + experimental: false, + ghSkillAgentName: "gemini-cli", + capabilities: EngineCapabilities{ + ToolsAllowlist: true, + MaxTurns: true, + MaxContinuations: false, // Gemini CLI does not support --max-autopilot-continues-style continuation mode + WebSearch: false, + NativeAgentFile: false, // Gemini does not support agent file natively; the compiler prepends the agent file content to prompt.txt + }, + dedicatedLLMGatewayPort: constants.GeminiLLMGatewayPort, + }, + cfg: googleCLIEngineConfig{ + log: logger.New("workflow:gemini_engine"), + apiKeySecretName: constants.GeminiAPIKey, + apiBaseURLEnvVar: "GEMINI_API_BASE_URL", + modelEnvVar: constants.GeminiCLIModelEnvVar, + trustWorkspaceEnvVar: "GEMINI_CLI_TRUST_WORKSPACE", + debugEnvValue: "gemini-cli:*", + defaultCLIBinary: "gemini", + // Auto-approve tool executions; skip workspace trust check so --yolo + // is not overridden to "default" approval mode by CLI v1.x. + // Stream-JSON output is required for log parsing. + cliArgs: []string{"--yolo", "--skip-trust", "--output-format", "stream-json"}, + configDir: ".gemini", + baseConfigEnvVar: "GH_AW_GEMINI_BASE_CONFIG", + secretValidationURL: "https://geminicli.com/docs/get-started/authentication/", + secretValidationLabel: "Gemini CLI", + configStepName: "Write Gemini Config", + executionStepName: "Execute Gemini CLI", + errorMoveStepName: "Move Gemini error files to artifact directory", + errorFileSrcGlob: "/tmp/gemini-client-error-*.json", + errorFileDstGlob: constants.TmpGeminiClientErrorGlob, + agentManifestFiles: []string{"GEMINI.md", "AGENTS.md"}, + agentManifestPrefixes: []string{".gemini/"}, + logParserScriptID: "parse_gemini_log", + logParserEngineName: "Gemini", + excludeAPIKeys: []string{constants.GeminiAPIKey}, + extraAllowedSecrets: []string{}, + mirrorAPIKeyAs: "", }, - dedicatedLLMGatewayPort: constants.GeminiLLMGatewayPort, }, } } -// GetModelEnvVarName returns the native environment variable name that the Gemini CLI uses -// for model selection. Setting GEMINI_MODEL is equivalent to passing --model to the CLI. -func (e *GeminiEngine) GetModelEnvVarName() string { - return constants.GeminiCLIModelEnvVar -} - -// GetRequiredSecretNames returns the list of secrets required by the Gemini engine -// This includes GEMINI_API_KEY and optionally MCP_GATEWAY_API_KEY, GITHUB_MCP_SERVER_TOKEN, -// HTTP MCP header secrets, and mcp-scripts secrets -func (e *GeminiEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { - geminiLog.Print("Collecting required secrets for Gemini engine") - secrets := []string{"GEMINI_API_KEY"} - - // Add common MCP secrets (MCP_GATEWAY_API_KEY if MCP servers present, mcp-scripts secrets) - secrets = append(secrets, collectCommonMCPSecrets(workflowData)...) - - // Add GitHub token for GitHub MCP server if present - if hasGitHubTool(workflowData.ParsedTools) { - geminiLog.Print("Adding GITHUB_MCP_SERVER_TOKEN secret") - secrets = append(secrets, "GITHUB_MCP_SERVER_TOKEN") - } - - // Add HTTP MCP header secret names - headerSecrets := collectHTTPMCPHeaderSecrets(workflowData.Tools) - for varName := range headerSecrets { - secrets = append(secrets, varName) - } - if len(headerSecrets) > 0 { - geminiLog.Printf("Added %d HTTP MCP header secrets", len(headerSecrets)) - } - - return secrets -} - -// GetSupportedEnvVarKeys returns the engine.env variable names that the Gemini engine -// supports as defined in the AWF specification. -func (e *GeminiEngine) GetSupportedEnvVarKeys() []string { - return []string{ - constants.GeminiAPIKey, - } -} - -// GetSecretValidationStep returns the secret validation step for the Gemini engine. -// Returns an empty step if custom command is specified. -func (e *GeminiEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { - return BuildDefaultSecretValidationStep( - workflowData, - []string{"GEMINI_API_KEY"}, - "Gemini CLI", - "https://geminicli.com/docs/get-started/authentication/", - ) -} - +// GetInstallationSteps returns the GitHub Actions steps needed to install Gemini CLI. +// Gemini installs from npm (@google/gemini-cli); Antigravity uses a different installer. func (e *GeminiEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { - geminiLog.Printf("Generating installation steps for Gemini engine: workflow=%s", workflowData.Name) + e.cfg.log.Printf("Generating installation steps for Gemini engine: workflow=%s", workflowData.Name) // Skip installation if custom command is specified if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - geminiLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) + e.cfg.log.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) return []GitHubActionStep{} } @@ -109,261 +85,3 @@ func (e *GeminiEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHub ) return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) } - -// 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. -// GetPreBundleSteps moves these files into /tmp/gh-aw/ so all artifact paths share a common -// ancestor under /tmp/gh-aw/ and the actions/upload-artifact LCA calculation stays correct. -func (e *GeminiEngine) GetDeclaredOutputFiles() []string { - return []string{ - constants.TmpGeminiClientErrorGlob, - } -} - -// GetAgentManifestFiles returns Gemini-specific instruction files that should be -// treated as security-sensitive manifests. A fork PR that modifies these files -// can redirect the agent's behaviour or expand which files it treats as instructions. -// GEMINI.md is the primary per-project context file; AGENTS.md is the cross-engine -// convention that Gemini CLI also reads. -func (e *GeminiEngine) GetAgentManifestFiles() []string { - return []string{"GEMINI.md", "AGENTS.md"} -} - -// GetAgentManifestPathPrefixes returns Gemini-specific config directory prefixes. -// The .gemini/ directory contains settings.json and other configuration that could -// expand which files are treated as instructions or alter agent behaviour. -// Protecting this directory prevents fork PRs from injecting malicious configuration. -func (e *GeminiEngine) GetAgentManifestPathPrefixes() []string { - return []string{".gemini/"} -} - -// GetPreBundleSteps returns a step that moves Gemini CLI error reports from /tmp/ into -// /tmp/gh-aw/ before the unified artifact upload. This keeps all artifact paths under -// /tmp/gh-aw/ so that actions/upload-artifact computes the correct least-common-ancestor -// path and downstream jobs find files at the expected locations. -func (e *GeminiEngine) GetPreBundleSteps(workflowData *WorkflowData) []GitHubActionStep { - return []GitHubActionStep{ - { - " - name: Move Gemini error files to artifact directory", - " if: always()", - " run: mv /tmp/gemini-client-error-*.json /tmp/gh-aw/ 2>/dev/null || true", - }, - } -} - -// GetExecutionSteps returns the GitHub Actions steps for executing Gemini -func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep { - geminiLog.Printf("Generating execution steps for Gemini engine: workflow=%s, firewall=%v", workflowData.Name, isFirewallEnabled(workflowData)) - - var steps []GitHubActionStep - - // Write .gemini/settings.json with context.includeDirectories and tools.core. - // This step runs after the MCP gateway setup (which may have written mcpServers config) - // and merges the context/tools settings into any existing settings.json. - settingsStep := e.generateGeminiSettingsStep(workflowData) - steps = append(steps, settingsStep) - - // Build gemini CLI arguments based on configuration - var geminiArgs []string - - // 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 }}). - modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" - - // Gemini CLI reads MCP config from .gemini/settings.json (project-level) - // The conversion script (convert_gateway_config_gemini.sh) writes settings.json - // during the MCP setup step, so no --mcp-config flag is needed here. - - // Auto-approve all tool executions (equivalent to Codex's --dangerously-bypass-approvals-and-sandbox) - // Without this, Gemini CLI's default approval mode rejects tool calls with "Tool execution denied by policy" - geminiArgs = append(geminiArgs, "--yolo") - - // Skip the workspace trust check so --yolo is not overridden to "default" approval mode. - // Gemini CLI v1.x checks whether the working directory is trusted and overrides --yolo - // with "default" approval mode (exit code 55) when the folder is untrusted. - // GEMINI_CLI_TRUST_WORKSPACE=true (also set in the step env) handles the same case via - // environment variable, but --skip-trust is more reliable when AWF's sandbox does not - // forward all host environment variables into the container. - geminiArgs = append(geminiArgs, "--skip-trust") - - // Add streaming JSON output (JSONL format, compatible with the log parser) - geminiArgs = append(geminiArgs, "--output-format", "stream-json") - - // Note: the --prompt argument is appended raw after shellJoinArgs below because it contains - // a shell command substitution ("$(cat ...)") that must NOT go through shellEscapeArg — - // single-quoting it would prevent shell expansion at runtime. - - // Build the command - commandName := "gemini" - if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - commandName = workflowData.EngineConfig.Command - } - - // Append the prompt arg raw (not through shellJoinArgs) to preserve shell expansion - geminiCommand := fmt.Sprintf(`%s %s --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, commandName, shellJoinArgs(geminiArgs)) - geminiCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + geminiCommand - - // Build the full command with AWF wrapping if enabled - var command string - firewallEnabled := isFirewallEnabled(workflowData) - if firewallEnabled { - // Get allowed domains: prefer the pre-warmed cache on WorkflowData to avoid - // re-running the expensive map+sort operation. - var allowedDomains string - if workflowData.CachedAllowedDomainsComputed { - allowedDomains = workflowData.CachedAllowedDomainsStr - } else { - allowedDomains = GetAllowedDomainsForEngine(constants.GeminiEngine, - workflowData.NetworkPermissions, - workflowData.Tools, - workflowData.Runtimes, - ) - } - // Add GHES/custom API target domains to the firewall allow-list when engine.api-target is set - if workflowData.EngineConfig != nil && workflowData.EngineConfig.APITarget != "" { - allowedDomains = mergeAPITargetDomains(allowedDomains, workflowData.EngineConfig.APITarget) - } - - npmPathSetup := GetNpmBinPathSetup() - geminiCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, geminiCommand) - // Add MCP CLI bin directory to PATH when cli-proxy is enabled - if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { - geminiCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, geminiCommandWithPath) - } - - command = BuildAWFCommand(AWFCommandConfig{ - EngineName: "gemini", - EngineCommand: geminiCommandWithPath, - LogFile: logFile, - WorkflowData: workflowData, - UsesTTY: false, - AllowedDomains: allowedDomains, - // Create the agent step summary file before AWF starts so it is accessible - // inside the sandbox. The agent writes its step summary content here, and the - // file is appended to $GITHUB_STEP_SUMMARY after secret redaction. - PathSetup: "touch " + AgentStepSummaryPath, - // Exclude every env var whose step-env value is a secret so the agent - // cannot read raw token values via bash tools (env / printenv). - ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"GEMINI_API_KEY"}), - }) - } else { - command = fmt.Sprintf(`set -o pipefail -printf '%%s' "$(date +%%s%%3N)" > %s -touch %s -(umask 177 && touch %s) -%s 2>&1 | tee -a %s`, AgentCLIStartMsPath, AgentStepSummaryPath, logFile, geminiCommand, logFile) - } - - // Build environment variables - env := map[string]string{ - "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", - "GH_AW_PROMPT": constants.AwPromptsFile, - // Tag the step as a GitHub AW agentic execution for discoverability by agents - "GITHUB_AW": "true", - "GITHUB_WORKSPACE": "${{ github.workspace }}", - "RUNNER_TEMP": "${{ runner.temp }}", - // Override GITHUB_STEP_SUMMARY with a path that exists inside the sandbox. - // The runner's original path is unreachable within the AWF isolated filesystem; - // we create this file before the agent starts and append it to the real - // $GITHUB_STEP_SUMMARY after secret redaction. - "GITHUB_STEP_SUMMARY": AgentStepSummaryPath, - // 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:*", - // Trust the workspace to prevent Gemini CLI v1.x from overriding --yolo to default - // approval mode when the workspace is untrusted, which causes exit code 55. - "GEMINI_CLI_TRUST_WORKSPACE": "true", - } - injectWorkflowCallNetworkAllowedEnv(env, workflowData) - // Indicate the phase: "agent" for the main run, "detection" for threat detection - // Include the compiler version so agents can identify which gh-aw version generated the workflow - if workflowData.IsDetectionRun { - env["GH_AW_PHASE"] = "detection" - } else { - env["GH_AW_PHASE"] = "agent" - } - if IsRelease() { - env["GH_AW_VERSION"] = GetVersion() - } else { - env["GH_AW_VERSION"] = "dev" - } - - // Add MCP config env var if needed (points to .gemini/settings.json for Gemini) - if HasMCPServers(workflowData) { - env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/.gemini/settings.json" - } - - // When the firewall (AWF) is enabled with --enable-api-proxy, point Gemini CLI at the - // LLM gateway sidecar instead of the real googleapis.com endpoint. - if firewallEnabled { - env["GEMINI_API_BASE_URL"] = fmt.Sprintf("http://host.docker.internal:%d", constants.GeminiLLMGatewayPort) - - // Set git identity environment variables so the first git commit succeeds inside the - // container. AWF's --env-all forwards these to the container, ensuring git does not - // rely on the host-side ~/.gitconfig which is not visible in the sandbox. - maps.Copy(env, getGitIdentityEnvVars()) - } - - // Add safe outputs env - applySafeOutputEnvToMap(env, workflowData) - - // Propagate W3C trace context so engine spans nest under the gh-aw.agent.setup span. - applyTraceContextEnvToMap(env) - - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" { - env["GH_AW_MAX_TURNS"] = workflowData.EngineConfig.MaxTurns - } else { - env["GH_AW_MAX_TURNS"] = compilerenv.BuildDefaultMaxTurnsExpression() - } - - // 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, 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 - } - - // Add custom environment variables from engine config. - // This allows users to override the default engine token expression (e.g. - // GEMINI_API_KEY: ${{ secrets.MY_ORG_GEMINI_KEY }}) via engine.env. - applyEngineCwdEnv(env, workflowData) - if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { - maps.Copy(env, workflowData.EngineConfig.Env) - } - - // Add custom environment variables from agent config - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil && len(agentConfig.Env) > 0 { - maps.Copy(env, agentConfig.Env) - geminiLog.Printf("Added %d custom env vars from agent config", len(agentConfig.Env)) - } - - // Generate the execution step - stepLines := []string{ - " - name: Execute Gemini CLI", - " id: agentic_execution", - } - - // Filter environment variables for security - allowedSecrets := e.GetRequiredSecretNames(workflowData) - filteredEnv := FilterEnvForSecrets(env, allowedSecrets) - - // Inject GH_TOKEN for CLI proxy (added after filtering since it uses a special - // fallback expression that is always allowed when cli-proxy is enabled) - addCliProxyGHTokenToEnv(filteredEnv, workflowData) - - // Format step with command and env - stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) - - steps = append(steps, GitHubActionStep(stepLines)) - return steps -} diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index b7f30d5c306..7960ffaf6cd 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -487,7 +487,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "Write Gemini Config", "Should have correct step name") @@ -502,7 +502,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "if [ -f", "Should check for existing settings.json") @@ -517,7 +517,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { "bash": []any{"grep", "git"}, }, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "run_shell_command(grep)", "Should include run_shell_command(grep) for bash grep") @@ -532,7 +532,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { "edit": map[string]any{}, }, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "write_file", "Should include write_file for edit tool") @@ -544,7 +544,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") // The JSON value must be single-quoted so YAML doesn't treat it as an object @@ -558,7 +558,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { "web-fetch": nil, }, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "web_fetch", "Should include web_fetch in tools.core when web-fetch is specified") @@ -569,7 +569,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { Name: "test-workflow", Tools: map[string]any{}, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.NotContains(t, content, "web_fetch", "Should not include web_fetch in tools.core when web-fetch is not specified") @@ -591,7 +591,7 @@ func TestGenerateGeminiSettingsStep(t *testing.T) { NoOp: &NoOpConfig{}, }, } - step := engine.generateGeminiSettingsStep(workflowData) + step := engine.generateSettingsStep(workflowData) content := strings.Join(step, "\n") assert.Contains(t, content, "run_shell_command(echo)", "Should include original restricted bash command") diff --git a/pkg/workflow/gemini_logs.go b/pkg/workflow/gemini_logs.go deleted file mode 100644 index 938c82d3877..00000000000 --- a/pkg/workflow/gemini_logs.go +++ /dev/null @@ -1,19 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var geminiLogsLog = logger.New("workflow:gemini_logs") - -// ParseLogMetrics parses Gemini CLI log output and extracts metrics. -// Gemini CLI outputs a single JSON response when using --output-format json. -// We parse the last valid JSON line (most complete response) and aggregate stats. -func (e *GeminiEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { - return parseStatsJSONLMetrics(logContent, verbose, "Gemini", geminiLogsLog) -} - -// GetLogParserScriptId returns the script ID for parsing Gemini logs -func (e *GeminiEngine) GetLogParserScriptId() string { - return "parse_gemini_log" -} diff --git a/pkg/workflow/gemini_mcp.go b/pkg/workflow/gemini_mcp.go deleted file mode 100644 index a51ea8e9d77..00000000000 --- a/pkg/workflow/gemini_mcp.go +++ /dev/null @@ -1,18 +0,0 @@ -package workflow - -import ( - "strings" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" -) - -var geminiMCPLog = logger.New("workflow:gemini_mcp") - -// RenderMCPConfig renders MCP server configuration for Gemini CLI -func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error { - geminiMCPLog.Printf("Rendering MCP config for Gemini: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools)) - - // Gemini uses JSON format without Copilot-specific fields and multi-line args - return renderDefaultJSONMCPConfig(yaml, tools, mcpTools, workflowData, constants.ShellMcpServersJsonPath) -} diff --git a/pkg/workflow/gemini_tools.go b/pkg/workflow/gemini_tools.go index fb2f6ea2deb..521cf2fe2f5 100644 --- a/pkg/workflow/gemini_tools.go +++ b/pkg/workflow/gemini_tools.go @@ -2,27 +2,14 @@ package workflow // This file provides Gemini engine tool configuration logic. // -// It handles two key responsibilities: +// computeGeminiToolsCore is a thin wrapper around computeGoogleCLIToolsCore that +// preserves the existing function signature for tests and callers while delegating +// all logic to the shared implementation. // -// 1. Tool Core Mapping (computeGeminiToolsCore): -// Converts neutral tool names from the workflow configuration into -// Gemini CLI built-in tool names for the tools.core allowlist in -// .gemini/settings.json. This restricts the agent to only the tools -// explicitly requested by the workflow. -// -// 2. Settings Step Generation (generateGeminiSettingsStep): -// Generates a GitHub Actions step that writes or merges .gemini/settings.json -// before the Gemini CLI execution. This step always sets: -// - context.includeDirectories: ["/tmp/"] so file tools can access /tmp/ -// - tools.core: derived from neutral tool configuration -// The merge approach ensures MCP server config (written by convert_gateway_config_gemini.sh) -// is preserved while adding the context and tool settings. +// The settings-step generator (generateSettingsStep) lives on googleCLIEngine and +// calls computeGoogleCLIToolsCore directly with the engine's configured logger. import ( - "encoding/json" - "fmt" - "sort" - "github.com/github/gh-aw/pkg/logger" ) @@ -42,144 +29,5 @@ var geminiToolsLog = logger.New("workflow:gemini_tools") // See: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/file-system.md // See: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/shell.md func computeGeminiToolsCore(tools map[string]any) []string { - // Always include essential read-only file system tools - toolsCore := []string{ - "glob", - "grep_search", - "list_directory", - "read_file", - "read_many_files", - } - - if tools == nil { - return toolsCore - } - - // Map bash neutral tool to run_shell_command - if bashConfig, hasBash := tools["bash"]; hasBash { - bashCommands, ok := bashConfig.([]any) - if !ok || len(bashCommands) == 0 { - // bash with no specific commands - allow all shell commands - geminiToolsLog.Print("bash (no specific commands) → run_shell_command") - toolsCore = append(toolsCore, "run_shell_command") - } else { - // Check for wildcard (* or :*) - hasWildcard := false - for _, cmd := range bashCommands { - if cmdStr, ok := cmd.(string); ok && (cmdStr == "*" || cmdStr == ":*") { - hasWildcard = true - break - } - } - if hasWildcard { - geminiToolsLog.Print("bash wildcard → run_shell_command") - toolsCore = append(toolsCore, "run_shell_command") - } else { - // Add an entry for each specific command: run_shell_command(cmd) - for _, cmd := range bashCommands { - if cmdStr, ok := cmd.(string); ok { - // Normalize trailing " *" wildcard (e.g. "jq *" → "jq") so that - // all engines emit the canonical prefix form (run_shell_command(jq)) - // regardless of whether the command was written with or without the wildcard. - normalized, _ := normalizeBashCommand(cmdStr) - entry := fmt.Sprintf("run_shell_command(%s)", normalized) - geminiToolsLog.Printf("bash %q → %s", cmdStr, entry) - toolsCore = append(toolsCore, entry) - } - } - } - } - } - - // Map edit neutral tool to write_file and replace (Gemini's file write tools) - if _, hasEdit := tools["edit"]; hasEdit { - geminiToolsLog.Print("edit → replace, write_file") - toolsCore = append(toolsCore, "replace") - toolsCore = append(toolsCore, "write_file") - } - - // Map web-fetch neutral tool to web_fetch (Gemini's native HTTP fetch tool) - // See: https://geminicli.com/docs/tools/web-fetch/ - if _, hasWebFetch := tools["web-fetch"]; hasWebFetch { - geminiToolsLog.Print("web-fetch → web_fetch") - toolsCore = append(toolsCore, "web_fetch") - } - - sort.Strings(toolsCore) - return toolsCore -} - -// generateGeminiSettingsStep creates a GitHub Actions step that writes the -// Gemini CLI project settings file (.gemini/settings.json) before execution. -// -// This step: -// 1. Sets context.includeDirectories to ["/tmp/"] so that Gemini CLI file system -// tools (write_file, replace) can access files in /tmp/ including -// /tmp/gh-aw/cache-memory/ and other agent working directories. -// 2. Sets tools.core to the list of built-in tools derived from the workflow's -// neutral tool configuration (bash → run_shell_command, edit → write_file/replace). -// 3. Merges the above settings with any existing .gemini/settings.json, which -// may have been written by convert_gateway_config_gemini.sh with MCP server -// configuration. The merge preserves the MCP server config while adding -// the context and tools settings. -func (e *GeminiEngine) generateGeminiSettingsStep(workflowData *WorkflowData) GitHubActionStep { - geminiToolsLog.Printf("Generating Gemini settings step for: %s", workflowData.Name) - - tools := workflowData.Tools - if tools == nil { - tools = make(map[string]any) - } - workflowDataWithEffectiveTools := *workflowData - workflowDataWithEffectiveTools.Tools = tools - tools = withMountedCLIShellCommandsInRestrictedBash(&workflowDataWithEffectiveTools) - - // Compute tools.core from neutral tool configuration - toolsCore := computeGeminiToolsCore(tools) - geminiToolsLog.Printf("tools.core entries: %d", len(toolsCore)) - - // Build the settings JSON object - config := map[string]any{ - "context": map[string]any{ - "includeDirectories": []string{"/tmp/"}, - }, - "tools": map[string]any{ - "core": toolsCore, - }, - } - - configJSON, err := json.Marshal(config) - if err != nil { - geminiToolsLog.Printf("ERROR: Failed to marshal Gemini settings: %v", err) - configJSON = []byte(`{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":[]}}`) - } - - // Generate a shell script that: - // - Creates the .gemini directory if needed - // - Merges settings into an existing settings.json (from MCP gateway setup), or - // - Creates a new settings.json when no MCP servers are configured - // - // The JSON config is passed via the GH_AW_GEMINI_BASE_CONFIG environment variable - // to avoid any shell quoting issues with special characters in the JSON. - // - // jq merge: '$existing * $base' means the RIGHT operand ($base) overrides the LEFT - // operand ($existing) for conflicting keys. Non-conflicting keys from $existing - // (e.g. mcpServers written by convert_gateway_config_gemini.sh) are preserved. - command := `mkdir -p "$GITHUB_WORKSPACE/.gemini" -SETTINGS="$GITHUB_WORKSPACE/.gemini/settings.json" -BASE_CONFIG="$GH_AW_GEMINI_BASE_CONFIG" -if [ -f "$SETTINGS" ]; then - MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') - echo "$MERGED" > "$SETTINGS" -else - echo "$BASE_CONFIG" > "$SETTINGS" -fi` - - stepLines := []string{ - " - name: Write Gemini Config", - } - env := map[string]string{ - "GH_AW_GEMINI_BASE_CONFIG": string(configJSON), - } - stepLines = FormatStepWithCommandAndEnv(stepLines, command, env) - return GitHubActionStep(stepLines) + return computeGoogleCLIToolsCore(tools, geminiToolsLog) } diff --git a/pkg/workflow/google_cli_engine.go b/pkg/workflow/google_cli_engine.go new file mode 100644 index 00000000000..b440bfa0a0e --- /dev/null +++ b/pkg/workflow/google_cli_engine.go @@ -0,0 +1,598 @@ +package workflow + +// This file provides the shared base for Google CLI-based agentic engines +// (Gemini and Antigravity). The two engines share the same execution model, +// settings-file format, MCP config approach, and log-parsing strategy — they +// differ only in engine-specific constants (binary name, API-key secret name, +// config directory, CLI flags, etc.). +// +// googleCLIEngine embeds BaseEngine and holds a googleCLIEngineConfig that +// carries every per-engine constant. GeminiEngine and AntigravityEngine embed +// googleCLIEngine and override only GetInstallationSteps (which are completely +// different — npm package vs. GCS binary installer). +// +// Shared utility functions: +// - appendBashTools – maps bash neutral config to run_shell_command entries +// - computeGoogleCLIToolsCore – full neutral→CLI tool name mapping +// +// computeGeminiToolsCore and computeAntigravityToolsCore in their respective +// files are thin wrappers that delegate here so existing tests remain unchanged. + +import ( + "encoding/json" + "fmt" + "maps" + "sort" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/workflow/compilerenv" +) + +// googleCLIEngineConfig holds all per-engine constants for Google CLI-based engines. +// Set every field in NewGeminiEngine / NewAntigravityEngine. +type googleCLIEngineConfig struct { + // log is the engine-instance logger (e.g. "workflow:gemini_engine"). + log *logger.Logger + + // apiKeySecretName is the GitHub Actions secret name for the engine API key + // (e.g. "GEMINI_API_KEY" or "ANTIGRAVITY_API_KEY"). + apiKeySecretName string + + // apiBaseURLEnvVar is the environment variable name used to override the + // API base URL when the LLM gateway proxy is active + // (e.g. "GEMINI_API_BASE_URL" or "ANTIGRAVITY_API_BASE_URL"). + apiBaseURLEnvVar string + + // modelEnvVar is the native CLI environment variable for model selection + // (e.g. "GEMINI_MODEL" or "ANTIGRAVITY_MODEL"). + modelEnvVar string + + // trustWorkspaceEnvVar is set to "true" to prevent CLI v1.x from overriding + // --yolo with "default" approval mode for untrusted workspaces + // (e.g. "GEMINI_CLI_TRUST_WORKSPACE" or "ANTIGRAVITY_CLI_TRUST_WORKSPACE"). + trustWorkspaceEnvVar string + + // debugEnvValue is the value of the DEBUG environment variable passed to the CLI + // to enable verbose internal debug channels (e.g. "gemini-cli:*"). + debugEnvValue string + + // defaultCLIBinary is the executable name when no engine.command override is set + // (e.g. "gemini" or "agy"). + defaultCLIBinary string + + // cliArgs are the pre-prompt CLI arguments appended before --prompt + // (e.g. ["--yolo", "--skip-trust", "--output-format", "stream-json"] for Gemini). + cliArgs []string + + // configDir is the project-level config directory relative to GITHUB_WORKSPACE + // (e.g. ".gemini" or ".antigravity"). + configDir string + + // baseConfigEnvVar is the environment variable name used to pass the JSON + // settings blob into the settings-write step + // (e.g. "GH_AW_GEMINI_BASE_CONFIG" or "GH_AW_ANTIGRAVITY_BASE_CONFIG"). + baseConfigEnvVar string + + // secretValidationURL is the documentation URL shown in the secret validation step. + secretValidationURL string + + // secretValidationLabel is the engine name shown in the secret validation step + // message (e.g. "Gemini CLI", "Antigravity CLI"). This may differ from + // GetDisplayName() which carries the full product name. + secretValidationLabel string + + // configStepName is the human-readable name for the settings-file write step + // (e.g. "Write Gemini Config" or "Write Antigravity Config"). + configStepName string + + // executionStepName is the human-readable name for the CLI execution step + // (e.g. "Execute Gemini CLI" or "Execute Antigravity CLI"). + executionStepName string + + // errorMoveStepName is the human-readable name for the pre-bundle error-file + // relocation step (e.g. "Move Gemini error files to artifact directory"). + errorMoveStepName string + + // errorFileSrcGlob is the glob pattern for error files produced by the CLI + // that must be moved into /tmp/gh-aw/ before the artifact upload + // (e.g. "/tmp/gemini-client-error-*.json"). + errorFileSrcGlob string + + // errorFileDstGlob is the declared output file glob under /tmp/gh-aw/ used + // in GetDeclaredOutputFiles (e.g. constants.TmpGeminiClientErrorGlob). + errorFileDstGlob string + + // agentManifestFiles are the engine-specific instruction files treated as + // security-sensitive manifests (e.g. ["GEMINI.md", "AGENTS.md"]). + agentManifestFiles []string + + // agentManifestPrefixes are the engine-specific config directory prefixes + // (e.g. [".gemini/"]). + agentManifestPrefixes []string + + // logParserScriptID is the JavaScript script ID for log parsing + // (e.g. "parse_gemini_log" or "parse_antigravity_log"). + logParserScriptID string + + // logParserEngineName is the display name passed to parseStatsJSONLMetrics + // (e.g. "Gemini" or "Antigravity"). + logParserEngineName string + + // excludeAPIKeys lists the API-key env var names passed to + // ComputeAWFExcludeEnvVarNames so they are stripped from the sandboxed env + // (e.g. ["GEMINI_API_KEY"] for Gemini, ["ANTIGRAVITY_API_KEY", "GEMINI_API_KEY"] for Antigravity). + excludeAPIKeys []string + + // extraAllowedSecrets are additional secrets prepended to the allowed-secrets + // list passed to FilterEnvForSecrets. Antigravity needs "GEMINI_API_KEY" here + // because it mirrors the Antigravity key into GEMINI_API_KEY for the proxy. + extraAllowedSecrets []string + + // mirrorAPIKeyAs, when non-empty, causes the value of apiKeySecretName to be + // copied into this env var if not already set. Antigravity sets this to + // "GEMINI_API_KEY" so the Gemini proxy sidecar receives a valid key. + mirrorAPIKeyAs string +} + +// googleCLIEngine is the shared embeddable base for Gemini and Antigravity engines. +type googleCLIEngine struct { + BaseEngine + cfg googleCLIEngineConfig +} + +// ── Shared utility functions ──────────────────────────────────────────────── + +// appendBashTools maps the bash neutral tool configuration to run_shell_command +// entries and appends them to toolsCore. log is used for debug messages. +// +// A single pass over bashCommands is used so that a wildcard found anywhere in +// the list (even after specific commands) causes only "run_shell_command" to be +// returned and any pre-wildcard specific entries are discarded. This preserves +// the semantics of "any wildcard means allow all shell commands". +func appendBashTools(toolsCore []string, bashConfig any, log *logger.Logger) []string { + bashCommands, ok := bashConfig.([]any) + if !ok || len(bashCommands) == 0 { + // bash with no specific commands – allow all shell commands + log.Print("bash (no specific commands) → run_shell_command") + return append(toolsCore, "run_shell_command") + } + + // Single pass: accumulate per-command entries in specific; return early on wildcard. + var specific []string + for _, cmd := range bashCommands { + cmdStr, ok := cmd.(string) + if !ok { + continue + } + if cmdStr == "*" || cmdStr == ":*" { + log.Print("bash wildcard → run_shell_command") + return append(toolsCore, "run_shell_command") + } + // Normalize trailing " *" wildcard (e.g. "jq *" → "jq") so that all + // engines emit the canonical prefix form (run_shell_command(jq)). + normalized, _ := normalizeBashCommand(cmdStr) + entry := fmt.Sprintf("run_shell_command(%s)", normalized) + log.Printf("bash %q → %s", cmdStr, entry) + specific = append(specific, entry) + } + return append(toolsCore, specific...) +} + +// computeGoogleCLIToolsCore maps neutral tool names to Google CLI built-in tool +// names for the tools.core allowlist in the engine settings file. +// +// Neutral tool → Google CLI tool mapping: +// - bash: [cmd, ...] → run_shell_command(cmd), ... (one entry per command) +// - bash: * or bash: nil → run_shell_command (allow all shell commands) +// - edit: {} → replace, write_file (file write tools) +// - web-fetch: {} → web_fetch +// +// Read-only file system tools are always included as they are essential for +// agentic workflows: glob, grep_search, list_directory, read_file, read_many_files. +func computeGoogleCLIToolsCore(tools map[string]any, log *logger.Logger) []string { + // Always include essential read-only file system tools. + toolsCore := []string{ + "glob", + "grep_search", + "list_directory", + "read_file", + "read_many_files", + } + + if tools == nil { + return toolsCore + } + + // Map bash neutral tool to run_shell_command. + if bashConfig, hasBash := tools["bash"]; hasBash { + toolsCore = appendBashTools(toolsCore, bashConfig, log) + } + + // Map edit neutral tool to write_file and replace (file write tools). + if _, hasEdit := tools["edit"]; hasEdit { + log.Print("edit → replace, write_file") + toolsCore = append(toolsCore, "replace") + toolsCore = append(toolsCore, "write_file") + } + + // Map web-fetch neutral tool to the native web_fetch tool. + if _, hasWebFetch := tools["web-fetch"]; hasWebFetch { + log.Print("web-fetch → web_fetch") + toolsCore = append(toolsCore, "web_fetch") + } + + sort.Strings(toolsCore) + return toolsCore +} + +// ── googleCLIEngine method implementations ─────────────────────────────────── + +// GetModelEnvVarName returns the native CLI environment variable for model selection. +func (e *googleCLIEngine) GetModelEnvVarName() string { + return e.cfg.modelEnvVar +} + +// GetSupportedEnvVarKeys returns the engine.env variable names this engine supports. +func (e *googleCLIEngine) GetSupportedEnvVarKeys() []string { + return []string{e.cfg.apiKeySecretName} +} + +// GetRequiredSecretNames returns the list of secrets required by the engine. +func (e *googleCLIEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { + e.cfg.log.Printf("Collecting required secrets for %s engine", e.GetDisplayName()) + secrets := []string{e.cfg.apiKeySecretName} + + // Add common MCP secrets (MCP_GATEWAY_API_KEY if MCP servers present, mcp-scripts secrets). + secrets = append(secrets, collectCommonMCPSecrets(workflowData)...) + + // Add GitHub token for GitHub MCP server if present. + if hasGitHubTool(workflowData.ParsedTools) { + e.cfg.log.Print("Adding GITHUB_MCP_SERVER_TOKEN secret") + secrets = append(secrets, "GITHUB_MCP_SERVER_TOKEN") + } + + // Add HTTP MCP header secret names. + headerSecrets := collectHTTPMCPHeaderSecrets(workflowData.Tools) + for varName := range headerSecrets { + secrets = append(secrets, varName) + } + if len(headerSecrets) > 0 { + e.cfg.log.Printf("Added %d HTTP MCP header secrets", len(headerSecrets)) + } + + return secrets +} + +// GetSecretValidationStep returns the secret validation step for the engine. +// Returns an empty step if a custom command is specified. +func (e *googleCLIEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { + return BuildDefaultSecretValidationStep( + workflowData, + []string{e.cfg.apiKeySecretName}, + e.cfg.secretValidationLabel, + e.cfg.secretValidationURL, + ) +} + +// GetDeclaredOutputFiles returns the output files the engine may produce. +func (e *googleCLIEngine) GetDeclaredOutputFiles() []string { + return []string{e.cfg.errorFileDstGlob} +} + +// GetAgentManifestFiles returns engine-specific instruction files treated as +// security-sensitive manifests. +func (e *googleCLIEngine) GetAgentManifestFiles() []string { + return e.cfg.agentManifestFiles +} + +// GetAgentManifestPathPrefixes returns engine-specific config directory prefixes. +func (e *googleCLIEngine) GetAgentManifestPathPrefixes() []string { + return e.cfg.agentManifestPrefixes +} + +// GetPreBundleSteps returns a step that moves CLI error reports from /tmp/ into +// /tmp/gh-aw/ before the unified artifact upload. +func (e *googleCLIEngine) GetPreBundleSteps(workflowData *WorkflowData) []GitHubActionStep { + return []GitHubActionStep{ + { + " - name: " + e.cfg.errorMoveStepName, + " if: always()", + fmt.Sprintf(" run: mv %s /tmp/gh-aw/ 2>/dev/null || true", e.cfg.errorFileSrcGlob), + }, + } +} + +// RenderMCPConfig renders MCP server configuration for the engine. +// Both Gemini and Antigravity use the JSON format without Copilot-specific fields. +func (e *googleCLIEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error { + e.cfg.log.Printf("Rendering MCP config for %s: tool_count=%d, mcp_tool_count=%d", + e.GetDisplayName(), len(tools), len(mcpTools)) + return renderDefaultJSONMCPConfig(yaml, tools, mcpTools, workflowData, constants.ShellMcpServersJsonPath) +} + +// ParseLogMetrics parses CLI log output and extracts metrics. +func (e *googleCLIEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { + return parseStatsJSONLMetrics(logContent, verbose, e.cfg.logParserEngineName, e.cfg.log) +} + +// GetLogParserScriptId returns the script ID for parsing engine logs. +func (e *googleCLIEngine) GetLogParserScriptId() string { + return e.cfg.logParserScriptID +} + +// generateSettingsStep creates a GitHub Actions step that writes or merges the +// engine's project settings file (e.g. .gemini/settings.json) before execution. +// +// This step: +// 1. Sets context.includeDirectories to ["/tmp/"] so that file-system tools +// (write_file, replace) can access files in /tmp/ including /tmp/gh-aw/. +// 2. Sets tools.core to the built-in tool list derived from the workflow's +// neutral tool configuration. +// 3. Merges the above settings with any existing settings.json (written by +// the MCP gateway setup script), preserving mcpServers config. +func (e *googleCLIEngine) generateSettingsStep(workflowData *WorkflowData) GitHubActionStep { + e.cfg.log.Printf("Generating %s settings step for: %s", e.GetDisplayName(), workflowData.Name) + + tools := workflowData.Tools + if tools == nil { + tools = make(map[string]any) + } + workflowDataWithEffectiveTools := *workflowData + workflowDataWithEffectiveTools.Tools = tools + tools = withMountedCLIShellCommandsInRestrictedBash(&workflowDataWithEffectiveTools) + + // Compute tools.core from neutral tool configuration. + toolsCore := computeGoogleCLIToolsCore(tools, e.cfg.log) + e.cfg.log.Printf("tools.core entries: %d", len(toolsCore)) + + // Build the settings JSON object. + config := map[string]any{ + "context": map[string]any{ + "includeDirectories": []string{"/tmp/"}, + }, + "tools": map[string]any{ + "core": toolsCore, + }, + } + + configJSON, err := json.Marshal(config) + if err != nil { + e.cfg.log.Printf("ERROR: Failed to marshal %s settings: %v", e.GetDisplayName(), err) + configJSON = []byte(`{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":[]}}`) + } + + // Generate a shell script that: + // - Creates the engine config directory if needed. + // - Merges settings into an existing settings.json (from MCP gateway setup), or + // - Creates a new settings.json when no MCP servers are configured. + // + // The JSON config is passed via an environment variable to avoid shell quoting + // issues with special characters in the JSON. + // + // jq merge: '$existing * $base' means the RIGHT operand ($base) overrides the + // LEFT operand ($existing) for conflicting keys. Non-conflicting keys from + // $existing (e.g. mcpServers) are preserved. + command := fmt.Sprintf( + `mkdir -p "$GITHUB_WORKSPACE/%[1]s" +SETTINGS="$GITHUB_WORKSPACE/%[1]s/settings.json" +BASE_CONFIG="$%[2]s" +if [ -f "$SETTINGS" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') + echo "$MERGED" > "$SETTINGS" +else + echo "$BASE_CONFIG" > "$SETTINGS" +fi`, + e.cfg.configDir, e.cfg.baseConfigEnvVar, + ) + + stepLines := []string{ + " - name: " + e.cfg.configStepName, + } + env := map[string]string{ + e.cfg.baseConfigEnvVar: string(configJSON), + } + stepLines = FormatStepWithCommandAndEnv(stepLines, command, env) + return GitHubActionStep(stepLines) +} + +// GetExecutionSteps returns the GitHub Actions steps for executing the CLI engine. +func (e *googleCLIEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep { + e.cfg.log.Printf("Generating execution steps for %s engine: workflow=%s, firewall=%v", + e.GetDisplayName(), workflowData.Name, isFirewallEnabled(workflowData)) + + var steps []GitHubActionStep + + // Write the engine settings file with context.includeDirectories and tools.core. + // This step runs after the MCP gateway setup (which may have written mcpServers + // config) and merges the context/tools settings into any existing settings.json. + settingsStep := e.generateSettingsStep(workflowData) + steps = append(steps, settingsStep) + + // Model is passed via the native model env var when explicitly configured to + // avoid embedding the value in the shell command (which fails template injection + // validation for GitHub Actions expressions like ${{ inputs.model }}). + modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" + + // Build CLI arguments. + cliArgs := make([]string, len(e.cfg.cliArgs)) + copy(cliArgs, e.cfg.cliArgs) + + // Build the command name. + commandName := e.cfg.defaultCLIBinary + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { + commandName = workflowData.EngineConfig.Command + } + + // Append the prompt arg raw (not through shellJoinArgs) to preserve shell expansion. + cliCommand := fmt.Sprintf(`%s %s --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, + commandName, shellJoinArgs(cliArgs)) + cliCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + cliCommand + + // Build the full command with AWF wrapping if the firewall is enabled. + var command string + firewallEnabled := isFirewallEnabled(workflowData) + if firewallEnabled { + // Get allowed domains: prefer the pre-warmed cache on WorkflowData to avoid + // re-running the expensive map+sort operation. + var allowedDomains string + if workflowData.CachedAllowedDomainsComputed { + allowedDomains = workflowData.CachedAllowedDomainsStr + } else { + allowedDomains = GetAllowedDomainsForEngine( + constants.EngineName(e.GetID()), + workflowData.NetworkPermissions, + workflowData.Tools, + workflowData.Runtimes, + ) + } + // Add GHES/custom API target domains to the firewall allow-list when + // engine.api-target is set. + if workflowData.EngineConfig != nil && workflowData.EngineConfig.APITarget != "" { + allowedDomains = mergeAPITargetDomains(allowedDomains, workflowData.EngineConfig.APITarget) + } + + npmPathSetup := GetNpmBinPathSetup() + cliCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, cliCommand) + // Add MCP CLI bin directory to PATH when cli-proxy is enabled. + if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { + cliCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, cliCommandWithPath) + } + + command = BuildAWFCommand(AWFCommandConfig{ + EngineName: e.GetID(), + EngineCommand: cliCommandWithPath, + LogFile: logFile, + WorkflowData: workflowData, + UsesTTY: false, + AllowedDomains: allowedDomains, + // Create the agent step summary file before AWF starts so it is accessible + // inside the sandbox. + PathSetup: "touch " + AgentStepSummaryPath, + // Exclude every env var whose step-env value is a secret so the agent + // cannot read raw token values via bash tools (env / printenv). + ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, e.cfg.excludeAPIKeys), + }) + } else { + command = fmt.Sprintf(`set -o pipefail +printf '%%s' "$(date +%%s%%3N)" > %s +touch %s +(umask 177 && touch %s) +%s 2>&1 | tee -a %s`, + AgentCLIStartMsPath, AgentStepSummaryPath, logFile, cliCommand, logFile) + } + + // Build environment variables. + env := map[string]string{ + e.cfg.apiKeySecretName: fmt.Sprintf("${{ secrets.%s }}", e.cfg.apiKeySecretName), + "GH_AW_PROMPT": constants.AwPromptsFile, + // Tag the step as a GitHub AW agentic execution for discoverability by agents. + "GITHUB_AW": "true", + "GITHUB_WORKSPACE": "${{ github.workspace }}", + "RUNNER_TEMP": "${{ runner.temp }}", + // Override GITHUB_STEP_SUMMARY with a path accessible inside the sandbox. + // We create this file before the agent starts and append it to the real + // $GITHUB_STEP_SUMMARY after secret redaction. + "GITHUB_STEP_SUMMARY": AgentStepSummaryPath, + // Enable verbose debug logging from the CLI. + "DEBUG": e.cfg.debugEnvValue, + // Trust the workspace to prevent CLI v1.x from overriding --yolo to default + // approval mode when the workspace is untrusted (exit code 55). + e.cfg.trustWorkspaceEnvVar: "true", + } + injectWorkflowCallNetworkAllowedEnv(env, workflowData) + // Indicate the phase: "agent" for the main run, "detection" for threat detection. + // Include the compiler version so agents can identify which gh-aw version generated the workflow. + if workflowData.IsDetectionRun { + env["GH_AW_PHASE"] = "detection" + } else { + env["GH_AW_PHASE"] = "agent" + } + if IsRelease() { + env["GH_AW_VERSION"] = GetVersion() + } else { + env["GH_AW_VERSION"] = "dev" + } + + // Add MCP config env var if needed. + if HasMCPServers(workflowData) { + env["GH_AW_MCP_CONFIG"] = fmt.Sprintf("${{ github.workspace }}/%s/settings.json", e.cfg.configDir) + } + + // When the firewall (AWF) is enabled with --enable-api-proxy, point the CLI at + // the LLM gateway sidecar instead of the real googleapis.com endpoint. + if firewallEnabled { + env[e.cfg.apiBaseURLEnvVar] = fmt.Sprintf("http://host.docker.internal:%d", e.getDedicatedLLMGatewayPort()) + + // Set git identity environment variables so the first git commit succeeds + // inside the container. + maps.Copy(env, getGitIdentityEnvVars()) + } + + // Add safe outputs env. + applySafeOutputEnvToMap(env, workflowData) + + // Propagate W3C trace context so engine spans nest under the gh-aw.agent.setup span. + applyTraceContextEnvToMap(env) + + if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" { + env["GH_AW_MAX_TURNS"] = workflowData.EngineConfig.MaxTurns + } else { + env["GH_AW_MAX_TURNS"] = compilerenv.BuildDefaultMaxTurnsExpression() + } + + // Set the model environment variable only when explicitly configured. + if modelConfigured { + e.cfg.log.Printf("Setting %s env var for model: %s", e.cfg.modelEnvVar, workflowData.EngineConfig.Model) + env[e.cfg.modelEnvVar] = workflowData.EngineConfig.Model + } + + // Add custom environment variables from engine config. + applyEngineCwdEnv(env, workflowData) + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + maps.Copy(env, workflowData.EngineConfig.Env) + } + + // Add custom environment variables from agent config. + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && len(agentConfig.Env) > 0 { + maps.Copy(env, agentConfig.Env) + e.cfg.log.Printf("Added %d custom env vars from agent config", len(agentConfig.Env)) + } + + // Mirror the primary API key into a secondary env var if configured. + // This runs after all env overrides so the mirror tracks the effective key value. + // Antigravity uses this to copy ANTIGRAVITY_API_KEY → GEMINI_API_KEY so the + // Gemini proxy sidecar can authenticate without requiring users to duplicate secrets. + if e.cfg.mirrorAPIKeyAs != "" { + if _, alreadySet := env[e.cfg.mirrorAPIKeyAs]; !alreadySet { + env[e.cfg.mirrorAPIKeyAs] = env[e.cfg.apiKeySecretName] + } + } + + // Generate the execution step. + stepLines := []string{ + " - name: " + e.cfg.executionStepName, + " id: agentic_execution", + } + + // Build the allowed-secrets list. extraAllowedSecrets are prepended so that + // FilterEnvForSecrets keeps those secrets even if GetRequiredSecretNames does + // not include them (e.g. Antigravity includes GEMINI_API_KEY here). + requiredSecrets := e.GetRequiredSecretNames(workflowData) + allowedSecrets := make([]string, 0, len(e.cfg.extraAllowedSecrets)+len(requiredSecrets)) + allowedSecrets = append(allowedSecrets, e.cfg.extraAllowedSecrets...) + allowedSecrets = append(allowedSecrets, requiredSecrets...) + filteredEnv := FilterEnvForSecrets(env, allowedSecrets) + + // Inject GH_TOKEN for CLI proxy (added after filtering since it uses a special + // fallback expression that is always allowed when cli-proxy is enabled). + addCliProxyGHTokenToEnv(filteredEnv, workflowData) + + // Format step with command and env. + stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) + + steps = append(steps, GitHubActionStep(stepLines)) + return steps +} From d8ab476db600ad91ea9a1dfce1275f5d93bb7cef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:54:29 +0000 Subject: [PATCH 4/7] docs(adr): add draft ADR-46367 for googleCLIEngine shared base consolidation --- ...gravity-gemini-shared-google-cli-engine.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adr/46367-consolidate-antigravity-gemini-shared-google-cli-engine.md diff --git a/docs/adr/46367-consolidate-antigravity-gemini-shared-google-cli-engine.md b/docs/adr/46367-consolidate-antigravity-gemini-shared-google-cli-engine.md new file mode 100644 index 00000000000..7a2b68c3eb9 --- /dev/null +++ b/docs/adr/46367-consolidate-antigravity-gemini-shared-google-cli-engine.md @@ -0,0 +1,50 @@ +# ADR-46367: Consolidate Antigravity and Gemini Engines into a Shared `googleCLIEngine` Base + +**Date**: 2026-07-18 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `GeminiEngine` and `AntigravityEngine` implementations were near-verbatim copies of each other across five file pairs, sharing ~90–95% identical code. Despite the obvious similarity, the two had already diverged in behavior: `computeGeminiToolsCore` used an inline two-pass bash-mapping loop, while `computeAntigravityToolsCore` had already been refactored to the canonical single-pass `appendBashTools` helper — a live behavioral inconsistency affecting tool allowlist generation. Both engines implement the `CodingAgentEngine` interface and share the same CLI invocation pattern (API key secret, CLI binary, config directory, log parser, MCP config rendering, secret filtering, etc.), differing only in constants and one installation method (npm vs. GCS binary). + +### Decision + +We will introduce a `googleCLIEngine` struct parameterized by a `googleCLIEngineConfig` config value that captures all per-engine constants (API key name, CLI binary, CLI flags, config directory, env var names, log parser identity, secret mirroring, etc.). Both `GeminiEngine` and `AntigravityEngine` will embed `googleCLIEngine` via Go struct embedding, inheriting the 13 shared methods. Each engine retains only `GetInstallationSteps`, which differs in mechanism (npm vs. GCS binary download). Four files (`antigravity_mcp.go`, `antigravity_logs.go`, `gemini_mcp.go`, `gemini_logs.go`) are deleted because their sole methods are now promoted from `googleCLIEngine`. + +### Alternatives Considered + +#### Alternative 1: Keep Separate Implementations, Establish a Sync Process + +Maintain the two independent engine implementations and introduce a code-review policy requiring that changes to one engine be mirrored to the other within the same PR. This avoids any structural change to the codebase. + +This was rejected because the behavioral drift (`computeGeminiToolsCore` two-pass vs. `computeAntigravityToolsCore` single-pass) demonstrates that manual sync policies fail in practice. Any future contributor adding a feature to one engine would need to remember to apply it to the other — a requirement that is not enforced by the compiler and will recur. + +#### Alternative 2: Interface-Based Composition via Constructor Injection + +Rather than struct embedding, extract the shared logic into standalone functions and pass them (or a helper object) into each engine's constructor. The engines would remain structurally independent but delegate shared operations to a common implementation. + +This was rejected because it requires duplicating method signatures on both engine types, adds indirection without eliminating the risk of divergence (the function signatures must still be called identically in both engines), and is less idiomatic Go for this pattern. Struct embedding provides zero-overhead promotion of methods and makes interface satisfaction verifiable by the compiler (`var _ CodingAgentEngine = (*GeminiEngine)(nil)`). + +### Consequences + +#### Positive +- Behavioral drift between the two engines is eliminated by construction: all 13 shared methods have exactly one implementation, and any change automatically applies to both engines. +- Code volume is reduced significantly: four files are deleted (`antigravity_mcp.go`, `antigravity_logs.go`, `gemini_mcp.go`, `gemini_logs.go`), and each engine file is reduced to a constructor plus one method. +- Adding a future third Google CLI engine (or onboarding a new engine with the same CLI pattern) requires only populating a `googleCLIEngineConfig` struct and implementing `GetInstallationSteps`. +- The `mirrorAPIKeyAs` field in `googleCLIEngineConfig` provides a first-class, documented mechanism for the Antigravity → Gemini API key mirroring behavior, making it explicit rather than buried in `GetExecutionSteps`. + +#### Negative +- Struct nesting is deeper: `AntigravityEngine` embeds `googleCLIEngine` which embeds `BaseEngine`, making field access paths longer (e.g., `e.cfg.log.Printf(...)` instead of `antigravityLog.Printf(...)`). +- `googleCLIEngineConfig` is a large struct with ~20 fields; future contributors must populate it correctly when adding a new engine, with no compile-time enforcement of required vs. optional fields. +- Go embedding promotes all `googleCLIEngine` methods onto both engine types silently — the promoted method set is not obvious from the engine type's own file, which may surprise contributors unfamiliar with Go embedding. + +#### Neutral +- Existing test function signatures (`computeAntigravityToolsCore`, `computeGeminiToolsCore`) are preserved as thin wrappers delegating to `computeGoogleCLIToolsCore`, avoiding test churn while exposing the shared implementation. +- The `generateAntigravitySettingsStep` and `generateGeminiSettingsStep` methods are replaced by the unified `generateSettingsStep` on `googleCLIEngine`; callers in test files are updated to call the new name. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 1b0d56ff22935e734091c3414711e4bb7d2c3b02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:07:49 +0000 Subject: [PATCH 5/7] chore: initial plan Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/agentic-auto-upgrade.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index 7035101ee35..e169cea32b6 100644 --- a/.github/workflows/agentic-auto-upgrade.yml +++ b/.github/workflows/agentic-auto-upgrade.yml @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade on: schedule: - - cron: "21 3 * * 5" # Weekly (auto-upgrade) + - cron: "11 4 * * 6" # Weekly (auto-upgrade) workflow_dispatch: permissions: From adeb58cbf58947dc37f1ff41b0daf07347a6b08f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:12:50 +0000 Subject: [PATCH 6/7] test(google-cli-engine): add mixed-order wildcard test; revert unrelated schedule change Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/gemini_engine_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index 7960ffaf6cd..46f8109d77b 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -417,6 +417,18 @@ func TestComputeGeminiToolsCore(t *testing.T) { assert.Contains(t, result, "run_shell_command", "Should include unrestricted run_shell_command for :* wildcard") }) + t.Run("bash with specific command before wildcard discards specific entry", func(t *testing.T) { + // When a wildcard appears anywhere in the list, only the unrestricted + // run_shell_command should be emitted; any pre-wildcard specific entries + // (e.g. run_shell_command(git)) must be discarded by the single-pass loop. + tools := map[string]any{ + "bash": []any{"git", "*"}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command", "Should include unrestricted run_shell_command when wildcard follows specific command") + assert.NotContains(t, result, "run_shell_command(git)", "Should discard specific entry when wildcard appears later in the list") + }) + t.Run("bash with no specific commands (nil) maps to unrestricted run_shell_command", func(t *testing.T) { tools := map[string]any{ "bash": nil, From 6ca3b016407dfabbd2e8749d8c7a3360b44c609f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:13:48 +0000 Subject: [PATCH 7/7] test(google-cli-engine): refocus wildcard test comment on behavior not implementation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/gemini_engine_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index 46f8109d77b..adbd735fb5f 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -418,9 +418,8 @@ func TestComputeGeminiToolsCore(t *testing.T) { }) t.Run("bash with specific command before wildcard discards specific entry", func(t *testing.T) { - // When a wildcard appears anywhere in the list, only the unrestricted - // run_shell_command should be emitted; any pre-wildcard specific entries - // (e.g. run_shell_command(git)) must be discarded by the single-pass loop. + // A wildcard anywhere in the list means "allow all shell commands". + // Specific entries that precede the wildcard must not appear in the output. tools := map[string]any{ "bash": []any{"git", "*"}, }