diff --git a/docs/adr/41921-extract-shared-cli-engine-execution-logic.md b/docs/adr/41921-extract-shared-cli-engine-execution-logic.md new file mode 100644 index 00000000000..28fbd21ff5b --- /dev/null +++ b/docs/adr/41921-extract-shared-cli-engine-execution-logic.md @@ -0,0 +1,46 @@ +# ADR-41921: Extract Shared CLI Engine Execution Logic into UniversalLLMConsumerEngine + +**Date**: 2026-06-27 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +The `pkg/workflow` package hosts multiple agentic engine implementations (CrushEngine, OpenCodeEngine, PiEngine) that each compile to GitHub Actions workflow steps. CrushEngine and OpenCodeEngine had ~90 lines of nearly-identical `GetExecutionSteps` logic: building a CLI command with a `run` subcommand, handling firewall-aware AWF wrapping, injecting the standard set of AWF environment variables (prompt path, workspace, MCP config, safe-output, trace context, max-turns, model), and formatting the resulting GitHub Actions step. Maintaining two copies increased the risk of one engine receiving a bug fix or behaviour change that the other did not. Additionally, Pi's `resolvePiBackend` function special-cased the `github-copilot` provider alias with an inline `strings.EqualFold` check, making the alias pattern non-reusable for future engines. + +### Decision + +We will consolidate the duplicated `GetExecutionSteps` logic into a single `BuildCLIEngineExecutionSteps` method on `UniversalLLMConsumerEngine`, parameterised by a `UniversalCLIEngineExecutionConfig` struct. Engine-specific parameters (binary name, extra CLI flags, permissions config file, step name, model env var name, timestamp behaviour) are passed via the struct; the common firewall-aware command construction, env injection, and step formatting live once in the shared method. We will also extract Pi's `github-copilot` alias lookup into a new `resolveBackendWithAliases` helper that accepts a caller-supplied alias map, making the pattern available to any future engine without ad-hoc string comparisons. + +### Alternatives Considered + +#### Alternative 1: Keep duplication, fix both engines in lockstep + +Each engine retains its own `GetExecutionSteps` implementation. Divergences are managed through code review discipline and cross-referencing comments. This avoids introducing an abstraction but perpetuates the maintenance burden: every future change to the execution pattern (e.g., a new env var, a firewall flag) must be applied in two places and reviewed for both. Given that the two implementations already diverged in minor ways (e.g., `WriteTimestamp` only in Crush), this approach has already shown its fragility and was rejected. + +#### Alternative 2: Extract a package-level function rather than a method on UniversalLLMConsumerEngine + +The shared logic could live in a standalone function `BuildCLIEngineExecutionSteps(e SomeInterface, workflowData, logFile, cfg)` instead of a method. This keeps the function decoupled from `UniversalLLMConsumerEngine` but requires defining an interface or passing the engine explicitly. Since all CLI engines already embed `UniversalLLMConsumerEngine` (which provides `ApplyUniversalProviderEnv`, `GetUniversalRequiredSecretNames`, etc.), making the shared logic a method on that struct is more idiomatic in Go and avoids a new interface. The method approach was chosen. + +### Consequences + +#### Positive +- Eliminates approximately 220 lines of duplicated code across CrushEngine and OpenCodeEngine; future engine additions can reuse the pattern with a single call. +- Bug fixes and behaviour changes to the standard execution path (env injection, firewall wrapping, step formatting) now apply to all engines simultaneously. +- `resolveBackendWithAliases` makes provider alias registration declarative and testable independently of any specific engine. +- `UniversalCLIEngineExecutionConfig` makes per-engine variation explicit and visible in one place rather than scattered across two large functions. + +#### Negative +- `UniversalCLIEngineExecutionConfig` is a new public struct that must be kept in sync as the execution pattern evolves; adding a new field requires updating all callers. +- Engine-specific flags encoded as struct fields (e.g., `WriteTimestamp: true` for Crush, `false` for OpenCode) are less discoverable than inline conditionals; a reviewer must trace from the config struct back to `BuildCLIEngineExecutionSteps` to understand their effect. +- Engines that diverge significantly from the shared pattern in the future may need to partially bypass `BuildCLIEngineExecutionSteps`, creating awkward partial-use of the abstraction. + +#### Neutral +- The `compilerenv` import moves from `crush_engine.go` and `opencode_engine.go` into `universal_llm_consumer_engine.go`; individual engine files become simpler. +- `GetUniversalRequiredSecretNames` is introduced as the method called inside `BuildCLIEngineExecutionSteps`, replacing the per-engine `GetRequiredSecretNames` calls; callers outside this path are unaffected. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/workflow/crush_engine.go b/pkg/workflow/crush_engine.go index 4c29f2963b2..5d7fea6efe4 100644 --- a/pkg/workflow/crush_engine.go +++ b/pkg/workflow/crush_engine.go @@ -2,11 +2,9 @@ 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 crushLog = logger.New("workflow:crush_engine") @@ -143,134 +141,16 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri crushLog.Printf("Generating execution steps for Crush engine: workflow=%s, firewall=%v", workflowData.Name, isFirewallEnabled(workflowData)) - var steps []GitHubActionStep - - // Step 1: Write .crush.json config (permissions) - configStep := e.generateCrushConfigStep(workflowData) - steps = append(steps, configStep) - - // Step 2: Build CLI arguments - var crushArgs []string - - modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" - - // Enable verbose logging for debugging in CI - crushArgs = append(crushArgs, "--verbose") - - // Prompt from file (positional argument to `crush run`). - // Keep this outside shellJoinArgs so command substitution expands at runtime. - promptArg := "\"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)\"" - - // Build command name - commandName := "crush" - if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - commandName = workflowData.EngineConfig.Command - } - crushCommand := fmt.Sprintf("%s run %s %s", commandName, shellJoinArgs(crushArgs), promptArg) - crushCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + crushCommand - - // AWF wrapping - firewallEnabled := isFirewallEnabled(workflowData) - var command string - if firewallEnabled { - // Resolve model for provider-specific domain allowlisting - model := "" - if modelConfigured { - model = workflowData.EngineConfig.Model - } - // Get allowed domains: prefer the pre-warmed cache on WorkflowData to avoid - // re-running the expensive map+sort operation. Note: crush uses model-specific - // domains; the cache is populated with the same model during compilation. - var allowedDomains string - if workflowData.CachedAllowedDomainsComputed { - allowedDomains = workflowData.CachedAllowedDomainsStr - } else { - // The model was validated by validateUniversalLLMConsumerModel before reaching here, - // so a malformed model (e.g. leading slash) must never occur. Panic is the correct - // response to an internal invariant violation. - allowedDomains = mustGetAllowedDomainsForEngineWithModel( - constants.CrushEngine, - model, - workflowData.NetworkPermissions, - workflowData.Tools, - workflowData.Runtimes, - ) - } - - npmPathSetup := GetNpmBinPathSetup() - crushCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, crushCommand) - if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { - crushCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, crushCommandWithPath) - } - - command = BuildAWFCommand(AWFCommandConfig{ - EngineName: "crush", - EngineCommand: crushCommandWithPath, - LogFile: logFile, - WorkflowData: workflowData, - UsesTTY: false, - AllowedDomains: allowedDomains, - }) - } else { - command = fmt.Sprintf("set -o pipefail\nprintf '%%s' \"$(date +%%s%%3N)\" > %s\n%s 2>&1 | tee -a %s", AgentCLIStartMsPath, crushCommand, logFile) - } - - env := map[string]string{ - "GH_AW_PROMPT": constants.AwPromptsFile, - "GITHUB_WORKSPACE": "${{ github.workspace }}", - "RUNNER_TEMP": "${{ runner.temp }}", - "NO_PROXY": "localhost,127.0.0.1", - } - injectWorkflowCallNetworkAllowedEnv(env, workflowData) - e.ApplyUniversalProviderEnv(env, workflowData, firewallEnabled) - - // MCP config path - if HasMCPServers(workflowData) { - env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/.crush.json" - } - - // 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() - } - - // Model env var (only when explicitly configured) - if modelConfigured { - crushLog.Printf("Setting %s env var for model: %s", - constants.CrushCLIModelEnvVar, workflowData.EngineConfig.Model) - env[constants.CrushCLIModelEnvVar] = workflowData.EngineConfig.Model - } - - // Custom env from engine config (allows provider override) - applyEngineCwdEnv(env, workflowData) - if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { - maps.Copy(env, workflowData.EngineConfig.Env) - } - - // Agent config env - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil && len(agentConfig.Env) > 0 { - maps.Copy(env, agentConfig.Env) - } - - // Build execution step - stepLines := []string{ - " - name: Execute Crush CLI", - " id: agentic_execution", - } - allowedSecrets := e.GetRequiredSecretNames(workflowData) - filteredEnv := FilterEnvForSecrets(env, allowedSecrets) - stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) - - steps = append(steps, GitHubActionStep(stepLines)) - return steps + return e.BuildCLIEngineExecutionSteps(workflowData, logFile, UniversalCLIEngineExecutionConfig{ + EngineConstant: constants.CrushEngine, + DefaultCommandName: "crush", + ExtraCLIArgs: []string{"--verbose"}, + MCPConfigFile: ".crush.json", + StepName: "Execute Crush CLI", + ConfigStep: e.generateCrushConfigStep(workflowData), + ModelEnvVarName: constants.CrushCLIModelEnvVar, + WriteTimestamp: true, + }) } // generateCrushConfigStep writes .crush.json with all permissions set to allow diff --git a/pkg/workflow/opencode_engine.go b/pkg/workflow/opencode_engine.go index 5017c2f8d9a..35753a2f876 100644 --- a/pkg/workflow/opencode_engine.go +++ b/pkg/workflow/opencode_engine.go @@ -2,11 +2,9 @@ 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 openCodeLog = logger.New("workflow:opencode_engine") @@ -110,118 +108,16 @@ func (e *OpenCodeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile s openCodeLog.Printf("Generating execution steps for OpenCode engine: workflow=%s, firewall=%v", workflowData.Name, isFirewallEnabled(workflowData)) - var steps []GitHubActionStep - - configStep := e.generateOpenCodeConfigStep(workflowData) - steps = append(steps, configStep) - - var openCodeArgs []string - modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" - - openCodeArgs = append(openCodeArgs, "--print-logs", "--log-level", "DEBUG") - promptArg := fmt.Sprintf("\"$(cat %s)\"", constants.AwPromptsFile) - - commandName := "opencode" - if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { - commandName = workflowData.EngineConfig.Command - } - openCodeCommand := fmt.Sprintf("%s run %s %s", commandName, shellJoinArgs(openCodeArgs), promptArg) - openCodeCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + openCodeCommand - - firewallEnabled := isFirewallEnabled(workflowData) - var command string - if firewallEnabled { - model := "" - if modelConfigured { - model = workflowData.EngineConfig.Model - } - // Get allowed domains: prefer the pre-warmed cache on WorkflowData to avoid - // re-running the expensive map+sort operation. Note: opencode uses model-specific - // domains; the cache is populated with the same model during compilation. - var allowedDomains string - if workflowData.CachedAllowedDomainsComputed { - allowedDomains = workflowData.CachedAllowedDomainsStr - } else { - // The model was validated by validateUniversalLLMConsumerModel before reaching here, - // so a malformed model (e.g. leading slash) must never occur. Panic is the correct - // response to an internal invariant violation. - allowedDomains = mustGetAllowedDomainsForEngineWithModel( - constants.OpenCodeEngine, - model, - workflowData.NetworkPermissions, - workflowData.Tools, - workflowData.Runtimes, - ) - } - - npmPathSetup := GetNpmBinPathSetup() - openCodeCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, openCodeCommand) - if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { - openCodeCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, openCodeCommandWithPath) - } - - command = BuildAWFCommand(AWFCommandConfig{ - EngineName: "opencode", - EngineCommand: openCodeCommandWithPath, - LogFile: logFile, - WorkflowData: workflowData, - UsesTTY: false, - AllowedDomains: allowedDomains, - }) - } else { - command = fmt.Sprintf("set -o pipefail\n%s 2>&1 | tee -a %s", openCodeCommand, logFile) - } - - env := map[string]string{ - "GH_AW_PROMPT": constants.AwPromptsFile, - "GITHUB_WORKSPACE": "${{ github.workspace }}", - "RUNNER_TEMP": "${{ runner.temp }}", - "NO_PROXY": "localhost,127.0.0.1", - } - injectWorkflowCallNetworkAllowedEnv(env, workflowData) - e.ApplyUniversalProviderEnv(env, workflowData, firewallEnabled) - - if HasMCPServers(workflowData) { - env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/opencode.jsonc" - } - - 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() - } - - if modelConfigured { - openCodeLog.Printf("Setting %s env var for model: %s", - constants.OpenCodeCLIModelEnvVar, workflowData.EngineConfig.Model) - env[constants.OpenCodeCLIModelEnvVar] = workflowData.EngineConfig.Model - } - - applyEngineCwdEnv(env, workflowData) - if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { - maps.Copy(env, workflowData.EngineConfig.Env) - } - - agentConfig := getAgentConfig(workflowData) - if agentConfig != nil && len(agentConfig.Env) > 0 { - maps.Copy(env, agentConfig.Env) - } - - stepLines := []string{ - " - name: Execute OpenCode CLI", - " id: agentic_execution", - } - allowedSecrets := e.GetRequiredSecretNames(workflowData) - filteredEnv := FilterEnvForSecrets(env, allowedSecrets) - stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) - - steps = append(steps, GitHubActionStep(stepLines)) - return steps + return e.BuildCLIEngineExecutionSteps(workflowData, logFile, UniversalCLIEngineExecutionConfig{ + EngineConstant: constants.OpenCodeEngine, + DefaultCommandName: "opencode", + ExtraCLIArgs: []string{"--print-logs", "--log-level", "DEBUG"}, + MCPConfigFile: "opencode.jsonc", + StepName: "Execute OpenCode CLI", + ConfigStep: e.generateOpenCodeConfigStep(workflowData), + ModelEnvVarName: constants.OpenCodeCLIModelEnvVar, + WriteTimestamp: false, + }) } // generateOpenCodeConfigStep writes opencode.jsonc with all permissions set to allow diff --git a/pkg/workflow/pi_engine.go b/pkg/workflow/pi_engine.go index d228c3700ab..ce43c83c24e 100644 --- a/pkg/workflow/pi_engine.go +++ b/pkg/workflow/pi_engine.go @@ -79,11 +79,9 @@ func resolvePiBackend(workflowData *WorkflowData) UniversalLLMBackend { } // "github-copilot" is Pi CLI's internal name for GitHub Copilot. Accept it as // an alias so workflows can use either "copilot/..." or "github-copilot/...". - parts := strings.SplitN(model, "/", 2) - if strings.EqualFold(parts[0], "github-copilot") { - return UniversalLLMBackendCopilot - } - backend, err := resolveUniversalLLMBackendFromModel(model) + backend, err := resolveBackendWithAliases(model, map[string]UniversalLLMBackend{ + "github-copilot": UniversalLLMBackendCopilot, + }) if err != nil { piLog.Printf("Could not resolve backend for Pi model %q, defaulting to copilot: %v", model, err) return UniversalLLMBackendCopilot diff --git a/pkg/workflow/universal_llm_consumer_engine.go b/pkg/workflow/universal_llm_consumer_engine.go index a1ed92ed621..5c517dd9981 100644 --- a/pkg/workflow/universal_llm_consumer_engine.go +++ b/pkg/workflow/universal_llm_consumer_engine.go @@ -8,6 +8,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/workflow/compilerenv" ) var universalLLMConsumerLog = logger.New("workflow:universal_llm_consumer_engine") @@ -173,3 +174,187 @@ func (e *UniversalLLMConsumerEngine) ApplyUniversalProviderEnv(env map[string]st env[profile.baseURLEnvName] = fmt.Sprintf("http://host.docker.internal:%d", profile.gatewayPort) } } + +// resolveBackendWithAliases is like resolveUniversalLLMBackendFromModel but also recognises +// extra provider name prefixes before falling back to the standard lookup. extraAliases maps +// lowercased provider names (the prefix before the first "/") to the corresponding +// UniversalLLMBackend. This lets engines that expose their own provider naming (e.g. Pi's +// "github-copilot") reuse the shared backend resolution logic without special-casing. +func resolveBackendWithAliases(model string, extraAliases map[string]UniversalLLMBackend) (UniversalLLMBackend, error) { + if len(extraAliases) > 0 { + model = strings.TrimSpace(model) + parts := strings.SplitN(model, "/", 2) + if len(parts) == 2 { + provider := strings.ToLower(strings.TrimSpace(parts[0])) + if backend, ok := extraAliases[provider]; ok { + universalLLMConsumerLog.Printf("Resolved backend via alias %q → %s", provider, backend) + return backend, nil + } + } + } + return resolveUniversalLLMBackendFromModel(model) +} + +// UniversalCLIEngineExecutionConfig holds the engine-specific parameters for +// BuildCLIEngineExecutionSteps. Engines that share the same execution pattern +// (a "run" subcommand, a JSON permissions config file, standard AWF env vars) can +// populate this struct to reuse the common execution step logic rather than +// duplicating it. +type UniversalCLIEngineExecutionConfig struct { + // EngineConstant is the engine name used for firewall allowed-domain resolution. + EngineConstant constants.EngineName + // DefaultCommandName is the CLI binary name used when engine.command is not set + // (e.g. "crush", "opencode"). + DefaultCommandName string + // ExtraCLIArgs are additional flags passed to the CLI run subcommand before the + // prompt argument (e.g. []string{"--verbose"} for Crush). + ExtraCLIArgs []string + // MCPConfigFile is the workspace-relative path of the permissions/MCP config file. + // It is used to populate GH_AW_MCP_CONFIG when MCP servers are configured. + MCPConfigFile string + // StepName is the GitHub Actions step name (e.g. "Execute Crush CLI"). + StepName string + // ConfigStep is the pre-built config-writing step that precedes the execution step. + // Typically writes a JSON file that grants all permissions so the agent never hangs + // on an interactive prompt in CI. + ConfigStep GitHubActionStep + // ModelEnvVarName is the native environment variable used by the CLI for model + // selection (e.g. "CRUSH_MODEL", "OPENCODE_MODEL"). When empty, model selection + // via env var is skipped. + ModelEnvVarName string + // WriteTimestamp controls whether the non-firewall fallback command writes the + // agent start timestamp to AgentCLIStartMsPath before running the engine. + WriteTimestamp bool +} + +// BuildCLIEngineExecutionSteps generates the GitHub Actions execution steps for a +// universal CLI engine (e.g. Crush, OpenCode). It handles firewall-aware command +// construction, common AWF environment variable injection, and step formatting. +// Engines call this from their GetExecutionSteps implementation, supplying engine- +// specific parameters via cfg. +func (e *UniversalLLMConsumerEngine) BuildCLIEngineExecutionSteps( + workflowData *WorkflowData, + logFile string, + cfg UniversalCLIEngineExecutionConfig, +) []GitHubActionStep { + universalLLMConsumerLog.Printf("Generating execution steps for %s engine: workflow=%s, firewall=%v", + cfg.DefaultCommandName, workflowData.Name, isFirewallEnabled(workflowData)) + + var steps []GitHubActionStep + + // Prepend the config step (writes permissions JSON to workspace). + if len(cfg.ConfigStep) > 0 { + steps = append(steps, cfg.ConfigStep) + } + + modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" + + // Build CLI command: run "". + cliArgs := append([]string{}, cfg.ExtraCLIArgs...) + promptArg := fmt.Sprintf("\"$(cat %s)\"", constants.AwPromptsFile) + commandName := cfg.DefaultCommandName + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { + commandName = workflowData.EngineConfig.Command + } + engineCommand := fmt.Sprintf("%s run %s %s", commandName, shellJoinArgs(cliArgs), promptArg) + engineCommand = getWorkspaceCommandPrefixFor(workflowData.EngineConfig) + engineCommand + + firewallEnabled := isFirewallEnabled(workflowData) + var command string + if firewallEnabled { + model := "" + if modelConfigured { + model = workflowData.EngineConfig.Model + } + // 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 { + // The model was validated before reaching here, so a malformed model + // (e.g. leading slash) must never occur. Panic is the correct response + // to an internal invariant violation. + allowedDomains = mustGetAllowedDomainsForEngineWithModel( + cfg.EngineConstant, + model, + workflowData.NetworkPermissions, + workflowData.Tools, + workflowData.Runtimes, + ) + } + + npmPathSetup := GetNpmBinPathSetup() + engineCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, engineCommand) + if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { + engineCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, engineCommandWithPath) + } + + command = BuildAWFCommand(AWFCommandConfig{ + EngineName: cfg.DefaultCommandName, + EngineCommand: engineCommandWithPath, + LogFile: logFile, + WorkflowData: workflowData, + UsesTTY: false, + AllowedDomains: allowedDomains, + }) + } else if cfg.WriteTimestamp { + command = fmt.Sprintf("set -o pipefail\nprintf '%%s' \"$(date +%%s%%3N)\" > %s\n%s 2>&1 | tee -a %s", + AgentCLIStartMsPath, engineCommand, logFile) + } else { + command = fmt.Sprintf("set -o pipefail\n%s 2>&1 | tee -a %s", engineCommand, logFile) + } + + env := map[string]string{ + "GH_AW_PROMPT": constants.AwPromptsFile, + "GITHUB_WORKSPACE": "${{ github.workspace }}", + "RUNNER_TEMP": "${{ runner.temp }}", + "NO_PROXY": "localhost,127.0.0.1", + } + injectWorkflowCallNetworkAllowedEnv(env, workflowData) + e.ApplyUniversalProviderEnv(env, workflowData, firewallEnabled) + + if HasMCPServers(workflowData) { + env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/" + cfg.MCPConfigFile + } + + 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() + } + + // Model env var (only when explicitly configured and the engine supports it). + if modelConfigured && cfg.ModelEnvVarName != "" { + universalLLMConsumerLog.Printf("Setting %s env var for model: %s", cfg.ModelEnvVarName, workflowData.EngineConfig.Model) + env[cfg.ModelEnvVarName] = workflowData.EngineConfig.Model + } + + // Custom env from engine config (allows provider key override). + applyEngineCwdEnv(env, workflowData) + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + maps.Copy(env, workflowData.EngineConfig.Env) + } + + // Agent config env. + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && len(agentConfig.Env) > 0 { + maps.Copy(env, agentConfig.Env) + } + + stepLines := []string{ + " - name: " + cfg.StepName, + " id: agentic_execution", + } + allowedSecrets := e.GetUniversalRequiredSecretNames(workflowData) + filteredEnv := FilterEnvForSecrets(env, allowedSecrets) + stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) + + steps = append(steps, GitHubActionStep(stepLines)) + return steps +}