-
Notifications
You must be signed in to change notification settings - Fork 355
Expand file tree
/
Copy pathawf_helpers.go
More file actions
723 lines (632 loc) · 29.9 KB
/
awf_helpers.go
File metadata and controls
723 lines (632 loc) · 29.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
// This file provides helper functions for AWF (Agentic Workflow Firewall) integration.
//
// AWF is the network firewall/sandbox used by gh-aw to control network egress for
// AI agent execution. This file consolidates common AWF logic that was previously
// duplicated across multiple engine implementations (Copilot, Claude, Codex).
//
// # Key Functions
//
// AWF Command Building:
// - BuildAWFCommand() - Builds complete AWF command with all arguments
// - BuildAWFArgs() - Constructs common AWF arguments from configuration
// - GetAWFCommandPrefix() - Determines AWF command (custom vs standard)
// - WrapCommandInShell() - Wraps engine command in shell for AWF execution
//
// AWF Configuration:
// - GetAWFDomains() - Combines allowed/blocked domains from various sources
// - GetSSLBumpArgs() - Returns SSL bump configuration arguments
// - GetAWFImageTag() - Returns pinned AWF image tag
//
// These functions extract shared AWF patterns from engine implementations,
// providing a consistent and maintainable approach to AWF integration.
package workflow
import (
"fmt"
"sort"
"strings"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/semverutil"
)
var awfHelpersLog = logger.New("workflow:awf_helpers")
// AWFCommandConfig contains configuration for building AWF commands.
// This struct centralizes all the parameters needed to construct an AWF-wrapped command.
type AWFCommandConfig struct {
// EngineName is the engine ID (e.g., "copilot", "claude", "codex")
EngineName string
// EngineCommand is the command to execute inside AWF
EngineCommand string
// LogFile is the path to the log file
LogFile string
// WorkflowData contains all workflow configuration
WorkflowData *WorkflowData
// UsesTTY indicates if the engine requires a TTY (e.g., Claude)
UsesTTY bool
// AllowedDomains is the comma-separated list of allowed domains
AllowedDomains string
// PathSetup is optional shell commands to run before the engine command
// (e.g., npm PATH setup)
PathSetup string
// ExcludeEnvVarNames is the list of environment variable names to exclude from
// the agent container's visible environment via --exclude-env. These are the env
// var keys whose step-env values contain secret references (${{ secrets.* }}).
// Computed from the engine's GetRequiredSecretNames() so that every secret-bearing
// variable is excluded — the agent can never read raw token values via `env`/`printenv`.
// Requires AWF v0.25.3+ for --exclude-env support.
ExcludeEnvVarNames []string
}
// BuildAWFCommand builds a complete AWF command with all arguments.
// This consolidates the AWF command building logic that was duplicated across
// Copilot, Claude, and Codex engines.
//
// Parameters:
// - config: AWF command configuration
//
// Returns:
// - string: Complete AWF command with arguments and wrapped engine command
func BuildAWFCommand(config AWFCommandConfig) string {
awfHelpersLog.Printf("Building AWF command for engine: %s", config.EngineName)
// Get AWF command prefix (custom or standard)
awfCommand := GetAWFCommandPrefix(config.WorkflowData)
// Build AWF arguments. The returned list contains only args that are safe to pass
// through shellJoinArgs. Expandable-var args (--container-workdir "${GITHUB_WORKSPACE}"
// and --mount "${RUNNER_TEMP}/...") are appended raw below so that shell variable
// expansion is not suppressed by single-quoting.
awfArgs := BuildAWFArgs(config)
// Build the expandable args string for args that need shell variable expansion.
// These MUST be appended as raw (unescaped) strings because single-quoting would
// prevent the runner's shell from expanding ${GITHUB_WORKSPACE} and ${RUNNER_TEMP}.
ghAwDir := "${RUNNER_TEMP}/gh-aw"
expandableArgs := fmt.Sprintf(
`--container-workdir "${GITHUB_WORKSPACE}" --mount "%s:%s:ro" --mount "%s:/host%s:ro"`,
ghAwDir, ghAwDir, ghAwDir, ghAwDir,
)
// When upload_artifact is configured, add a read-write mount for the staging directory
// so the model can copy files there from inside the container. The parent ${RUNNER_TEMP}/gh-aw
// is mounted :ro above; this child mount overrides access for the staging subdirectory only.
// The staging directory must already exist on the host (created in Write Safe Outputs Config step).
if config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil {
stagingDir := "${RUNNER_TEMP}/gh-aw/safeoutputs/upload-artifacts"
expandableArgs += fmt.Sprintf(` --mount "%s:%s:rw"`, stagingDir, stagingDir)
awfHelpersLog.Print("Added read-write mount for upload_artifact staging directory")
}
// Add --allow-host-service-ports for services with port mappings.
// This is appended as a raw (expandable) arg because the value contains
// ${{ job.services.<id>.ports['<port>'] }} expressions that include single quotes.
// These expressions are resolved by the GitHub Actions runner before shell execution,
// so they must not be shell-escaped.
if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" {
expandableArgs += fmt.Sprintf(` --allow-host-service-ports "%s"`, config.WorkflowData.ServicePortExpressions)
awfHelpersLog.Printf("Added --allow-host-service-ports with %s", config.WorkflowData.ServicePortExpressions)
}
// Wrap engine command in shell (command already includes any internal setup like npm PATH)
shellWrappedCommand := WrapCommandInShell(config.EngineCommand)
// Pre-create the agent stdio log file with restrictive permissions (0600) before
// starting the AWF container. tee would otherwise create it with the default
// umask (0644), leaving secrets (e.g. MCP gateway tokens) world-readable on the
// runner host until the secret-redaction step runs.
preCreateLog := fmt.Sprintf("(umask 177 && touch %s)", shellEscapeArg(config.LogFile))
// Build the complete command with proper formatting
var command string
if config.PathSetup != "" {
// Include path setup before AWF command (runs on host before AWF)
command = fmt.Sprintf(`set -o pipefail
%s
%s
# shellcheck disable=SC1003
%s %s %s \
-- %s 2>&1 | tee -a %s`,
config.PathSetup,
preCreateLog,
awfCommand,
expandableArgs,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
} else {
command = fmt.Sprintf(`set -o pipefail
%s
# shellcheck disable=SC1003
%s %s %s \
-- %s 2>&1 | tee -a %s`,
preCreateLog,
awfCommand,
expandableArgs,
shellJoinArgs(awfArgs),
shellWrappedCommand,
shellEscapeArg(config.LogFile))
}
awfHelpersLog.Print("Successfully built AWF command")
return command
}
// BuildAWFArgs constructs common AWF arguments from configuration.
// This extracts the shared AWF argument building logic from engine implementations.
//
// Parameters:
// - config: AWF command configuration
//
// Returns:
// - []string: List of AWF arguments (safe args only; expandable-var args like
// --container-workdir and --mount are handled by BuildAWFCommand)
func BuildAWFArgs(config AWFCommandConfig) []string {
awfHelpersLog.Printf("Building AWF args for engine: %s", config.EngineName)
firewallConfig := getFirewallConfig(config.WorkflowData)
agentConfig := getAgentConfig(config.WorkflowData)
var awfArgs []string
// Add TTY flag if needed (Claude requires this)
if config.UsesTTY {
awfArgs = append(awfArgs, "--tty")
}
// Pass all environment variables to the container, but exclude every variable whose
// step-env value comes from a GitHub Actions secret. AWF's API proxy (--enable-api-proxy)
// handles authentication for these tokens transparently, so the container does not need
// the raw values. Excluding them via --exclude-env prevents a prompt-injected agent from
// exfiltrating tokens through bash tools such as `env` or `printenv`.
// The caller computes ExcludeEnvVarNames from ComputeAWFExcludeEnvVarNames() so that every
// secret-bearing variable is covered — not just a hardcoded subset.
// --exclude-env requires AWF v0.25.3+; skip the flags for workflows that pin an older version.
awfArgs = append(awfArgs, "--env-all")
if awfSupportsExcludeEnv(firewallConfig) {
// Sort for deterministic output in compiled lock files.
sortedExclude := make([]string, len(config.ExcludeEnvVarNames))
copy(sortedExclude, config.ExcludeEnvVarNames)
sort.Strings(sortedExclude)
for _, excludedVar := range sortedExclude {
awfArgs = append(awfArgs, "--exclude-env", excludedVar)
}
} else {
awfHelpersLog.Printf("Skipping --exclude-env: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFExcludeEnvMinVersion)
}
// Note: --container-workdir "${GITHUB_WORKSPACE}" and --mount "${RUNNER_TEMP}/gh-aw:..."
// are intentionally NOT added here. They contain shell variable references that require
// double-quote expansion. These args are appended raw in BuildAWFCommand to ensure
// ${GITHUB_WORKSPACE} and ${RUNNER_TEMP} are expanded by the runner's shell.
// Add custom mounts from agent config if specified
if agentConfig != nil && len(agentConfig.Mounts) > 0 {
// Sort mounts for consistent output
sortedMounts := make([]string, len(agentConfig.Mounts))
copy(sortedMounts, agentConfig.Mounts)
sort.Strings(sortedMounts)
for _, mount := range sortedMounts {
awfArgs = append(awfArgs, "--mount", mount)
}
awfHelpersLog.Printf("Added %d custom mounts from agent config", len(sortedMounts))
}
// Add allowed domains. When the value contains ${{ }} GitHub Actions expressions,
// shellEscapeArg (via shellJoinArgs) double-quotes it so the expression is preserved
// for GA evaluation. Otherwise it escapes or quotes only when needed (typically using
// single quotes for shell-special content), which safely handles wildcards like
// *.domain.com without shell glob expansion.
awfArgs = append(awfArgs, "--allow-domains", config.AllowedDomains)
// Add blocked domains if specified
blockedDomains := formatBlockedDomains(config.WorkflowData.NetworkPermissions)
if blockedDomains != "" {
// Same quoting rationale as --allow-domains above
awfArgs = append(awfArgs, "--block-domains", blockedDomains)
awfHelpersLog.Printf("Added blocked domains: %s", blockedDomains)
}
// Set log level
awfLogLevel := string(constants.AWFDefaultLogLevel)
if firewallConfig != nil && firewallConfig.LogLevel != "" {
awfLogLevel = firewallConfig.LogLevel
}
awfArgs = append(awfArgs, "--log-level", awfLogLevel)
awfArgs = append(awfArgs, "--proxy-logs-dir", string(constants.AWFProxyLogsDir))
awfArgs = append(awfArgs, "--audit-dir", string(constants.AWFAuditDir))
// Always add --enable-host-access: needed for the API proxy sidecar
// (to reach host.docker.internal:<port>) and for MCP gateway communication
awfArgs = append(awfArgs, "--enable-host-access")
awfHelpersLog.Print("Added --enable-host-access for API proxy and MCP gateway")
// Pin AWF Docker image version to match the installed binary version
awfImageTag := getAWFImageTag(firewallConfig)
awfArgs = append(awfArgs, "--image-tag", awfImageTag)
awfHelpersLog.Printf("Pinned AWF image tag to %s", awfImageTag)
// Skip pulling images since they are pre-downloaded
awfArgs = append(awfArgs, "--skip-pull")
awfHelpersLog.Print("Using --skip-pull since images are pre-downloaded")
// Enable API proxy sidecar (always required for LLM gateway)
awfArgs = append(awfArgs, "--enable-api-proxy")
awfHelpersLog.Print("Added --enable-api-proxy for LLM API proxying")
// Enable CLI proxy sidecar when the cli-proxy feature flag is set.
// Start the difc-proxy on the host and tell AWF where to connect
// (firewall v0.26.0+).
if isFeatureEnabled(constants.CliProxyFeatureFlag, config.WorkflowData) {
if awfSupportsCliProxy(firewallConfig) {
awfArgs = append(awfArgs, "--difc-proxy-host", "host.docker.internal:18443")
awfArgs = append(awfArgs, "--difc-proxy-ca-cert", "/tmp/gh-aw/difc-proxy-tls/ca.crt")
awfHelpersLog.Print("Added --difc-proxy-host and --difc-proxy-ca-cert for CLI proxy sidecar")
} else {
awfHelpersLog.Printf("Skipping CLI proxy flags: AWF version %q is older than minimum %s", getAWFImageTag(firewallConfig), constants.AWFCliProxyMinVersion)
}
}
// Add custom API targets if configured in engine.env
// This allows AWF's credential isolation and firewall to work with custom endpoints
// (e.g., corporate LLM routers, Azure OpenAI, self-hosted APIs)
openaiTarget := extractAPITargetHost(config.WorkflowData, "OPENAI_BASE_URL")
if openaiTarget != "" {
awfArgs = append(awfArgs, "--openai-api-target", openaiTarget)
awfHelpersLog.Printf("Added --openai-api-target=%s", openaiTarget)
}
anthropicTarget := extractAPITargetHost(config.WorkflowData, "ANTHROPIC_BASE_URL")
if anthropicTarget != "" {
awfArgs = append(awfArgs, "--anthropic-api-target", anthropicTarget)
awfHelpersLog.Printf("Added --anthropic-api-target=%s", anthropicTarget)
}
// Pass base path if URL contains a path component
// This is required for endpoints with path prefixes (e.g., Databricks /serving-endpoints,
// Azure OpenAI /openai/deployments/<name>, corporate LLM routers with path-based routing)
openaiBasePath := extractAPIBasePath(config.WorkflowData, "OPENAI_BASE_URL")
if openaiBasePath != "" {
awfArgs = append(awfArgs, "--openai-api-base-path", openaiBasePath)
awfHelpersLog.Printf("Added --openai-api-base-path=%s", openaiBasePath)
}
anthropicBasePath := extractAPIBasePath(config.WorkflowData, "ANTHROPIC_BASE_URL")
if anthropicBasePath != "" {
awfArgs = append(awfArgs, "--anthropic-api-base-path", anthropicBasePath)
awfHelpersLog.Printf("Added --anthropic-api-base-path=%s", anthropicBasePath)
}
// Add Copilot API target for custom Copilot endpoints (GHEC, GHES, or custom).
// Resolved from engine.api-target (explicit) or GITHUB_COPILOT_BASE_URL in engine.env (implicit).
if copilotTarget := GetCopilotAPITarget(config.WorkflowData); copilotTarget != "" {
awfArgs = append(awfArgs, "--copilot-api-target", copilotTarget)
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")
if geminiBasePath != "" {
awfArgs = append(awfArgs, "--gemini-api-base-path", geminiBasePath)
awfHelpersLog.Printf("Added --gemini-api-base-path=%s", geminiBasePath)
}
// Add SSL Bump support for HTTPS content inspection (v0.9.0+)
sslBumpArgs := getSSLBumpArgs(firewallConfig)
awfArgs = append(awfArgs, sslBumpArgs...)
// Add custom args if specified in firewall config
if firewallConfig != nil && len(firewallConfig.Args) > 0 {
awfArgs = append(awfArgs, firewallConfig.Args...)
}
// Add custom args from agent config if specified
if agentConfig != nil && len(agentConfig.Args) > 0 {
awfArgs = append(awfArgs, agentConfig.Args...)
awfHelpersLog.Printf("Added %d custom args from agent config", len(agentConfig.Args))
}
// Pass memory limit to AWF container if specified in agent config
if agentConfig != nil && agentConfig.Memory != "" {
awfArgs = append(awfArgs, "--memory-limit", agentConfig.Memory)
awfHelpersLog.Printf("Set AWF memory limit to %s", agentConfig.Memory)
}
awfHelpersLog.Printf("Built %d AWF arguments", len(awfArgs))
return awfArgs
}
// GetAWFCommandPrefix determines the AWF command to use (custom or standard).
// This extracts the common pattern for determining AWF command from agent config.
//
// Parameters:
// - workflowData: The workflow data containing agent configuration
//
// Returns:
// - string: The AWF command to use (e.g., "sudo -E awf" or custom command)
func GetAWFCommandPrefix(workflowData *WorkflowData) string {
agentConfig := getAgentConfig(workflowData)
if agentConfig != nil && agentConfig.Command != "" {
awfHelpersLog.Printf("Using custom AWF command: %s", agentConfig.Command)
return agentConfig.Command
}
awfHelpersLog.Print("Using standard AWF command")
return string(constants.AWFDefaultCommand)
}
// WrapCommandInShell wraps an engine command in a shell invocation for AWF execution.
// This is needed because AWF requires commands to be wrapped in shell for proper execution.
//
// Parameters:
// - command: The engine command to wrap (may include PATH setup and other initialization)
//
// Returns:
// - string: Shell-wrapped command suitable for AWF execution
func WrapCommandInShell(command string) string {
awfHelpersLog.Print("Wrapping command in shell for AWF execution")
// Escape single quotes in the command by replacing ' with '\''
escapedCommand := strings.ReplaceAll(command, "'", "'\\''")
// Wrap in shell invocation
return fmt.Sprintf("/bin/bash -c '%s'", escapedCommand)
}
// extractAPITargetHost extracts the hostname from a custom API base URL in engine.env.
// This supports custom OpenAI-compatible or Anthropic-compatible endpoints (e.g., internal
// LLM routers, Azure OpenAI) while preserving AWF's credential isolation and firewall features.
//
// The function:
// 1. Checks if the specified env var (e.g., "OPENAI_BASE_URL") exists in engine.env
// 2. Extracts the hostname from the URL (e.g., "https://llm-router.internal.example.com/v1" → "llm-router.internal.example.com")
// 3. Returns empty string if no custom URL is configured or if the URL is invalid
//
// Parameters:
// - workflowData: The workflow data containing engine configuration
// - envVar: The environment variable name (e.g., "OPENAI_BASE_URL", "ANTHROPIC_BASE_URL")
//
// Returns:
// - string: The hostname to use as --openai-api-target or --anthropic-api-target, or empty string if not configured
//
// Example:
//
// engine:
// id: codex
// env:
// OPENAI_BASE_URL: "https://llm-router.internal.example.com/v1"
// OPENAI_API_KEY: ${{ secrets.LLM_ROUTER_KEY }}
//
// extractAPITargetHost(workflowData, "OPENAI_BASE_URL")
// // Returns: "llm-router.internal.example.com"
func extractAPITargetHost(workflowData *WorkflowData, envVar string) string {
// Check if engine config and env are available
if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Env == nil {
return ""
}
// Get the custom base URL from engine.env
baseURL, exists := workflowData.EngineConfig.Env[envVar]
if !exists || baseURL == "" {
return ""
}
// Extract hostname from URL
// URLs can be:
// - "https://llm-router.internal.example.com/v1" → "llm-router.internal.example.com"
// - "http://localhost:8080/v1" → "localhost:8080"
// - "api.openai.com" → "api.openai.com" (treated as hostname)
// Remove protocol prefix if present
host := baseURL
if idx := strings.Index(host, "://"); idx != -1 {
host = host[idx+3:]
}
// Remove path suffix if present (everything after first /)
if idx := strings.Index(host, "/"); idx != -1 {
host = host[:idx]
}
// Validate that we have a non-empty hostname
if host == "" {
awfHelpersLog.Printf("Invalid %s URL (no hostname): %s", envVar, baseURL)
return ""
}
awfHelpersLog.Printf("Extracted API target host from %s: %s", envVar, host)
return host
}
// extractAPIBasePath extracts the path component from a custom API base URL in engine.env.
// Returns the path prefix (e.g., "/serving-endpoints") or empty string if no path is present.
// Root-only paths ("/") and empty paths return empty string.
//
// This is used to pass --openai-api-base-path and --anthropic-api-base-path to AWF when
// the configured base URL contains a path (e.g., Databricks serving endpoints, Azure OpenAI
// deployments, or corporate LLM routers with path-based routing).
func extractAPIBasePath(workflowData *WorkflowData, envVar string) string {
if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Env == nil {
return ""
}
baseURL, exists := workflowData.EngineConfig.Env[envVar]
if !exists || baseURL == "" {
return ""
}
// Remove protocol prefix if present
host := baseURL
if idx := strings.Index(host, "://"); idx != -1 {
host = host[idx+3:]
}
// Extract path (everything after the first /)
if idx := strings.Index(host, "/"); idx != -1 {
path := host[idx:] // e.g., "/serving-endpoints"
// Strip query string or fragment if present
if qi := strings.IndexAny(path, "?#"); qi != -1 {
path = path[:qi]
}
// Remove trailing slashes; a root-only path "/" becomes "" and returns empty
path = strings.TrimRight(path, "/")
if path != "" {
awfHelpersLog.Printf("Extracted API base path from %s: %s", envVar, path)
return path
}
}
return ""
}
// GetCopilotAPITarget returns the effective Copilot API target hostname, checking in order:
// 1. engine.api-target (explicit, takes precedence)
// 2. GITHUB_COPILOT_BASE_URL in engine.env (implicit, derived from the configured Copilot base URL)
//
// This mirrors the pattern used by other engines:
// - 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 {
// Explicit engine.api-target takes precedence.
if workflowData != nil && workflowData.EngineConfig != nil && workflowData.EngineConfig.APITarget != "" {
return workflowData.EngineConfig.APITarget
}
// Fallback: derive from the well-known GITHUB_COPILOT_BASE_URL env var.
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 != "" {
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.
//
// Only env var names whose step-env values WILL contain a ${{ secrets.* }} reference are
// included, so non-secret vars (e.g. GH_DEBUG: "1" in mcp-scripts) are never excluded.
//
// Parameters:
// - workflowData: the workflow being compiled
// - coreSecretVarNames: engine-specific fixed secret env var names (e.g. ["COPILOT_GITHUB_TOKEN"])
//
// The function augments coreSecretVarNames with:
// - MCP_GATEWAY_API_KEY when MCP servers are present
// - GITHUB_MCP_SERVER_TOKEN when the GitHub tool is present
// - HTTP MCP header secret var names (values always contain ${{ secrets.* }})
// - mcp-scripts env var names whose values contain ${{ secrets.* }}
// - engine.env var names whose values contain ${{ secrets.* }}
// - agent.env var names whose values contain ${{ secrets.* }}
func ComputeAWFExcludeEnvVarNames(workflowData *WorkflowData, coreSecretVarNames []string) []string {
seen := make(map[string]bool)
var names []string
addUnique := func(name string) {
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
// Core secret vars for this engine (always contain secret references).
for _, name := range coreSecretVarNames {
addUnique(name)
}
// MCP gateway API key is always a secret when MCP servers are present.
if HasMCPServers(workflowData) {
addUnique("MCP_GATEWAY_API_KEY")
}
// GitHub MCP server token is always a secret when the GitHub tool is present.
if hasGitHubTool(workflowData.ParsedTools) {
addUnique("GITHUB_MCP_SERVER_TOKEN")
}
// HTTP MCP header secrets: values are always ${{ secrets.* }} references.
for varName := range collectHTTPMCPHeaderSecrets(workflowData.Tools) {
addUnique(varName)
}
// mcp-scripts env vars: only add those whose configured values contain a secret reference.
// (Non-secret vars like GH_DEBUG: "1" must NOT be excluded.)
if workflowData.MCPScripts != nil {
for _, toolConfig := range workflowData.MCPScripts.Tools {
for envName, envValue := range toolConfig.Env {
if strings.Contains(envValue, "${{ secrets.") {
addUnique(envName)
}
}
}
}
// engine.env vars that contain a secret reference.
if workflowData.EngineConfig != nil {
for varName, varValue := range workflowData.EngineConfig.Env {
if strings.Contains(varValue, "${{ secrets.") {
addUnique(varName)
}
}
}
// agent.env vars that contain a secret reference.
agentConfig := getAgentConfig(workflowData)
if agentConfig != nil {
for varName, varValue := range agentConfig.Env {
if strings.Contains(varValue, "${{ secrets.") {
addUnique(varName)
}
}
}
// GH_TOKEN when cli-proxy is enabled: the token is passed in the AWF step env for the
// host difc-proxy but must be excluded from the agent container.
if isFeatureEnabled(constants.CliProxyFeatureFlag, workflowData) {
addUnique("GH_TOKEN")
}
awfHelpersLog.Printf("Computed %d AWF env vars to exclude", len(names))
return names
}
// addCliProxyGHTokenToEnv adds GH_TOKEN to the AWF step environment when the
// cli-proxy feature is enabled. The token is NOT used by AWF or its cli-proxy
// sidecar directly — the host difc-proxy (started by start_cli_proxy.sh) already
// has it. However, --env-all passes all step env vars into the agent container,
// so we explicitly set GH_TOKEN here to ensure --exclude-env GH_TOKEN can
// reliably strip it regardless of how the token enters the environment.
// The token is excluded from the agent container via --exclude-env GH_TOKEN, so only
// inject it when the effective AWF version supports both cli-proxy flags and
// --exclude-env.
//
// #nosec G101 -- This is NOT a hardcoded credential. It is a GitHub Actions expression
// template that is resolved at runtime by the GitHub Actions runner.
func addCliProxyGHTokenToEnv(env map[string]string, workflowData *WorkflowData) {
firewallConfig := getFirewallConfig(workflowData)
if isFeatureEnabled(constants.CliProxyFeatureFlag, workflowData) &&
isFirewallEnabled(workflowData) &&
awfSupportsCliProxy(firewallConfig) &&
awfSupportsExcludeEnv(firewallConfig) {
env["GH_TOKEN"] = "${{ secrets.GH_AW_GITHUB_TOKEN || github.token }}"
awfHelpersLog.Print("Added GH_TOKEN to env for CLI proxy (excluded from agent container)")
}
}
// awfSupportsExcludeEnv returns true when the effective AWF version supports --exclude-env.
//
// The --exclude-env flag was introduced in AWF v0.25.3. Any workflow that pins an explicit
// version older than v0.25.3 must not emit --exclude-env or the run will fail at startup.
//
// Special cases:
// - No version override (firewallConfig is nil or has no Version): use DefaultFirewallVersion
// which is always ≥ AWFExcludeEnvMinVersion → returns true.
// - "latest": always returns true (latest is always a new release).
// - Any semver string ≥ AWFExcludeEnvMinVersion: returns true.
// - Any semver string < AWFExcludeEnvMinVersion: returns false.
// - Non-semver string (e.g. a branch name): returns false (conservative).
func awfSupportsExcludeEnv(firewallConfig *FirewallConfig) bool {
var versionStr string
if firewallConfig != nil && firewallConfig.Version != "" {
versionStr = firewallConfig.Version
} else {
// No override → use the default, which is always ≥ the minimum.
return true
}
// "latest" means the newest release — always supports the flag.
if strings.EqualFold(versionStr, "latest") {
return true
}
// Normalise the v-prefix for semverutil.Compare.
minVersion := string(constants.AWFExcludeEnvMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}
// awfSupportsCliProxy returns true when the effective AWF version supports --difc-proxy-host
// and --difc-proxy-ca-cert.
//
// These flags were introduced in AWF v0.26.0 (replacing the earlier --enable-cli-proxy).
// Any workflow that pins an explicit version older than v0.26.0 must not emit CLI proxy
// flags or the run will fail at startup.
//
// Special cases:
// - No version override (firewallConfig is nil or has no Version): use DefaultFirewallVersion
// and compare against AWFCliProxyMinVersion.
// - "latest": always returns true (latest is always a new release).
// - Any semver string ≥ AWFCliProxyMinVersion: returns true.
// - Any semver string < AWFCliProxyMinVersion: returns false.
// - Non-semver string (e.g. a branch name): returns false (conservative).
func awfSupportsCliProxy(firewallConfig *FirewallConfig) bool {
var versionStr string
if firewallConfig != nil && firewallConfig.Version != "" {
versionStr = firewallConfig.Version
} else {
// No override → use the default version for comparison.
versionStr = string(constants.DefaultFirewallVersion)
}
// "latest" means the newest release — always supports the flag.
if strings.EqualFold(versionStr, "latest") {
return true
}
// Normalise the v-prefix for semverutil.Compare.
minVersion := string(constants.AWFCliProxyMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}