Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/smoke-gemini.lock.yml

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

19 changes: 17 additions & 2 deletions docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ The specified hostname must also be listed in `network.allowed` for the firewall

#### Custom API Endpoints via Environment Variables

Three environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), and `GITHUB_COPILOT_BASE_URL` (for `copilot`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container.
Three environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), `GITHUB_COPILOT_BASE_URL` (for `copilot`), and `GEMINI_API_BASE_URL` (for `gemini`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

This sentence says "Three environment variables" but then lists four (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, GITHUB_COPILOT_BASE_URL, GEMINI_API_BASE_URL). Update the count or rephrase (e.g., "The following environment variables...") to avoid confusing readers.

Suggested change
Three environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), `GITHUB_COPILOT_BASE_URL` (for `copilot`), and `GEMINI_API_BASE_URL` (for `gemini`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container.
The following environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), `GITHUB_COPILOT_BASE_URL` (for `copilot`), and `GEMINI_API_BASE_URL` (for `gemini`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container.

Copilot uses AI. Check for mistakes.

This enables workflows to use internal LLM routers, Azure OpenAI deployments, corporate Copilot proxies, or other compatible endpoints without bypassing AWF's security model.

Expand Down Expand Up @@ -205,7 +205,22 @@ network:

`GITHUB_COPILOT_BASE_URL` is used as a fallback when `engine.api-target` is not explicitly set. If both are configured, `engine.api-target` takes precedence.

The custom hostname is extracted from the URL and passed to the AWF `--openai-api-target`, `--anthropic-api-target`, or `--copilot-api-target` flag automatically at compile time. No additional configuration is required.
For Gemini workflows routed through a custom Gemini-compatible endpoint:

```yaml wrap
engine:
id: gemini
env:
GEMINI_API_BASE_URL: "https://gemini-proxy.internal.example.com"
GEMINI_API_KEY: ${{ secrets.PROXY_API_KEY }}

network:
allowed:
- github.com
- gemini-proxy.internal.example.com
```

The custom hostname is extracted from the URL and passed to the AWF `--openai-api-target`, `--anthropic-api-target`, `--copilot-api-target`, or `--gemini-api-target` flag automatically at compile time. No additional configuration is required.

### Engine Command-Line Arguments

Expand Down
43 changes: 43 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,21 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
awfHelpersLog.Printf("Added --copilot-api-target=%s", copilotTarget)
}

// Add Gemini API target for the LLM gateway proxy.
// Unlike OpenAI/Anthropic/Copilot where AWF has built-in default routing,
// Gemini requires an explicit target so the proxy knows where to forward requests.
// Defaults to generativelanguage.googleapis.com when the engine is Gemini.
if geminiTarget := GetGeminiAPITarget(config.WorkflowData, config.EngineName); geminiTarget != "" {
awfArgs = append(awfArgs, "--gemini-api-target", geminiTarget)
awfHelpersLog.Printf("Added --gemini-api-target=%s", geminiTarget)
}

geminiBasePath := extractAPIBasePath(config.WorkflowData, "GEMINI_API_BASE_URL")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The geminiBasePath extraction and --gemini-api-base-path arg are added unconditionally (whenever GEMINI_API_BASE_URL is set), while --gemini-api-target is gated on GetGeminiAPITarget returning non-empty. For consistency, consider gating this block the same way, or extracting both into GetGeminiAPITarget to keep the proxy arg logic in one place.

if geminiBasePath != "" {
awfArgs = append(awfArgs, "--gemini-api-base-path", geminiBasePath)
awfHelpersLog.Printf("Added --gemini-api-base-path=%s", geminiBasePath)
}
Comment on lines +318 to +331
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

GEMINI_API_BASE_URL is now treated as a compile-time signal to set --gemini-api-target/--gemini-api-base-path, but it’s also a runtime env var that GeminiEngine currently sets to the LLM gateway URL when the firewall is enabled. In pkg/workflow/gemini_engine.go, GEMINI_API_BASE_URL is set to host.docker.internal: and then later engine.env is copied into the step env (maps.Copy), which would overwrite the proxy URL if the user configures GEMINI_API_BASE_URL (causing Gemini CLI to bypass the gateway and fail because GEMINI_API_KEY is excluded, or bypass the intended credential isolation). Consider ensuring that in firewall mode GEMINI_API_BASE_URL is always forced to the LLM gateway URL after applying engine.env (or strip GEMINI_API_BASE_URL from engine.env at runtime and use it only for generating the AWF flags).

Copilot uses AI. Check for mistakes.

// Add SSL Bump support for HTTPS content inspection (v0.9.0+)
sslBumpArgs := getSSLBumpArgs(firewallConfig)
awfArgs = append(awfArgs, sslBumpArgs...)
Expand Down Expand Up @@ -491,6 +506,7 @@ func extractAPIBasePath(workflowData *WorkflowData, envVar string) string {
// - Codex: OPENAI_BASE_URL → --openai-api-target
// - Claude: ANTHROPIC_BASE_URL → --anthropic-api-target
// - Copilot: GITHUB_COPILOT_BASE_URL → --copilot-api-target (fallback when api-target not set)
// - Gemini: GEMINI_API_BASE_URL → --gemini-api-target (default: generativelanguage.googleapis.com)
//
// Returns empty string if neither source is configured.
func GetCopilotAPITarget(workflowData *WorkflowData) string {
Expand All @@ -503,6 +519,33 @@ func GetCopilotAPITarget(workflowData *WorkflowData) string {
return extractAPITargetHost(workflowData, "GITHUB_COPILOT_BASE_URL")
}

// DefaultGeminiAPITarget is the default Gemini API endpoint hostname.
// AWF's proxy sidecar needs this target to forward Gemini API requests, since
// unlike OpenAI/Anthropic/Copilot, the proxy has no built-in default handler for Gemini.
const DefaultGeminiAPITarget = "generativelanguage.googleapis.com"

// GetGeminiAPITarget returns the effective Gemini API target hostname for the LLM gateway proxy.
// Unlike other engines where AWF has built-in default routing, Gemini requires an explicit target.
//
// Resolution order:
// 1. GEMINI_API_BASE_URL in engine.env (custom endpoint)
// 2. Default: generativelanguage.googleapis.com (when engine is "gemini")
//
// Returns empty string if the engine is not Gemini and no custom GEMINI_API_BASE_URL is configured.
func GetGeminiAPITarget(workflowData *WorkflowData, engineName string) string {
// Check for custom GEMINI_API_BASE_URL in engine.env
if customTarget := extractAPITargetHost(workflowData, "GEMINI_API_BASE_URL"); customTarget != "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The string literal "gemini" is used here for engine name comparison. Consider defining a const EngineGemini = "gemini" (alongside other engine name constants if they exist) to avoid magic strings and make refactoring safer.

return customTarget
}

// Default to the standard Gemini API endpoint when engine is Gemini
if engineName == "gemini" {
return DefaultGeminiAPITarget
}

return ""
}

// ComputeAWFExcludeEnvVarNames returns the list of environment variable names that must be
// excluded from the agent container's visible environment via AWF's --exclude-env flag.
//
Expand Down
215 changes: 215 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,3 +919,218 @@ func TestAWFSupportsCliProxy(t *testing.T) {
})
}
}

// TestGetGeminiAPITarget tests the GetGeminiAPITarget helper that resolves the effective
// Gemini API target from GEMINI_API_BASE_URL in engine.env or the default endpoint.
func TestGetGeminiAPITarget(t *testing.T) {
tests := []struct {
name string
workflowData *WorkflowData
engineName string
expected string
}{
{
name: "returns default target for gemini engine with no custom URL",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "gemini",
},
},
engineName: "gemini",
expected: "generativelanguage.googleapis.com",
},
{
name: "custom GEMINI_API_BASE_URL takes precedence over default",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "gemini",
Env: map[string]string{
"GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1",
},
},
},
engineName: "gemini",
expected: "gemini-proxy.internal.company.com",
},
{
name: "returns empty for non-gemini engine without custom URL",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "claude",
},
},
engineName: "claude",
expected: "",
},
{
name: "returns empty when workflowData is nil",
workflowData: nil,
engineName: "gemini",
expected: "generativelanguage.googleapis.com",
},
Comment on lines +966 to +970
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The test case name "returns empty when workflowData is nil" doesn’t match the asserted behavior (it expects the default Gemini hostname). Renaming the test case to reflect that nil workflowData with engineName=="gemini" falls back to the default target would make the intent clearer.

Copilot uses AI. Check for mistakes.
{
name: "returns custom target for non-gemini engine with GEMINI_API_BASE_URL",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "custom",
Env: map[string]string{
"GEMINI_API_BASE_URL": "https://custom-proxy.example.com",
},
},
},
engineName: "custom",
expected: "custom-proxy.example.com",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetGeminiAPITarget(tt.workflowData, tt.engineName)
assert.Equal(t, tt.expected, result, "GetGeminiAPITarget should return expected hostname")
})
}
}

// TestAWFGeminiAPITargetFlags tests that BuildAWFArgs includes --gemini-api-target flag
// for the Gemini engine with default and custom endpoints.
func TestAWFGeminiAPITargetFlags(t *testing.T) {
t.Run("includes default gemini-api-target flag for gemini engine", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "gemini",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

config := AWFCommandConfig{
EngineName: "gemini",
WorkflowData: workflowData,
AllowedDomains: "github.com",
}

args := BuildAWFArgs(config)
argsStr := strings.Join(args, " ")

assert.Contains(t, argsStr, "--gemini-api-target", "Should include --gemini-api-target flag")
assert.Contains(t, argsStr, "generativelanguage.googleapis.com", "Should include default Gemini API hostname")
})

t.Run("includes custom gemini-api-target flag when GEMINI_API_BASE_URL is configured", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "gemini",
Env: map[string]string{
"GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1",
"GEMINI_API_KEY": "${{ secrets.GEMINI_PROXY_KEY }}",
},
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

config := AWFCommandConfig{
EngineName: "gemini",
WorkflowData: workflowData,
AllowedDomains: "github.com",
}

args := BuildAWFArgs(config)
argsStr := strings.Join(args, " ")

assert.Contains(t, argsStr, "--gemini-api-target", "Should include --gemini-api-target flag")
assert.Contains(t, argsStr, "gemini-proxy.internal.company.com", "Should include custom Gemini hostname")
})

t.Run("does not include gemini-api-target for non-gemini engine without custom URL", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "claude",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

config := AWFCommandConfig{
EngineName: "claude",
WorkflowData: workflowData,
AllowedDomains: "github.com",
}

args := BuildAWFArgs(config)
argsStr := strings.Join(args, " ")

assert.NotContains(t, argsStr, "--gemini-api-target", "Should not include --gemini-api-target for non-gemini engine")
})

t.Run("includes gemini-api-base-path when custom URL has path component", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "gemini",
Env: map[string]string{
"GEMINI_API_BASE_URL": "https://gemini-proxy.company.com/serving-endpoints",
"GEMINI_API_KEY": "${{ secrets.GEMINI_PROXY_KEY }}",
},
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

config := AWFCommandConfig{
EngineName: "gemini",
WorkflowData: workflowData,
AllowedDomains: "github.com",
}

args := BuildAWFArgs(config)
argsStr := strings.Join(args, " ")

assert.Contains(t, argsStr, "--gemini-api-base-path", "Should include --gemini-api-base-path flag")
assert.Contains(t, argsStr, "/serving-endpoints", "Should include the path component")
})
}

// TestGeminiEngineIncludesGeminiAPITarget tests that the Gemini engine execution
// step includes --gemini-api-target when firewall is enabled.
func TestGeminiEngineIncludesGeminiAPITarget(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "gemini",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

engine := NewGeminiEngine()
steps := engine.GetExecutionSteps(workflowData, "test.log")

if len(steps) < 2 {
t.Fatal("Expected at least two execution steps (settings + execution)")
}

// steps[0] = Write Gemini Settings, steps[1] = Execute Gemini CLI
stepContent := strings.Join(steps[1], "\n")

assert.Contains(t, stepContent, "--gemini-api-target", "Should include --gemini-api-target flag")
assert.Contains(t, stepContent, "generativelanguage.googleapis.com", "Should include default Gemini API hostname")
}
Comment on lines +994 to +1136
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

Given the new GEMINI_API_BASE_URL behavior, it would be valuable to add an end-to-end test that covers the intended precedence in firewall mode: when engine.env sets GEMINI_API_BASE_URL to a custom endpoint, the generated AWF command should include --gemini-api-target/--gemini-api-base-path for routing, while the step env passed to the Gemini CLI should still set GEMINI_API_BASE_URL to the LLM gateway URL (so the CLI doesn’t bypass the proxy). The current tests verify the flags, but not that runtime env stays pointed at the gateway under this configuration.

Copilot uses AI. Check for mistakes.
6 changes: 6 additions & 0 deletions pkg/workflow/domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,12 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri
base = mergeAPITargetDomains(base, copilotAPITarget)
}

// Add Gemini API target domains so GH_AW_ALLOWED_DOMAINS stays in sync with --allow-domains.
// Resolved from GEMINI_API_BASE_URL in engine.env or default generativelanguage.googleapis.com.
if geminiAPITarget := GetGeminiAPITarget(data, engineID); geminiAPITarget != "" {
base = mergeAPITargetDomains(base, geminiAPITarget)
}

return base
}

Expand Down
Loading