From 5d2a0f870fd85bb7ade188bc4a904b0fdfdb41a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:30:16 +0000 Subject: [PATCH 1/4] Initial plan From cad3429af2aa288dfe90656313711adee112b89e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:46:04 +0000 Subject: [PATCH 2/4] Split pkg/workflow/awf_config.go into types/build/schema/policy files Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../skills/awf-release-integrator/SKILL.md | 3 + pkg/workflow/README.md | 2 +- pkg/workflow/awf_config.go | 713 ------------------ pkg/workflow/awf_config_build.go | 546 ++++++++++++++ pkg/workflow/awf_config_policy.go | 96 +++ pkg/workflow/awf_config_schema.go | 101 +++ 6 files changed, 747 insertions(+), 714 deletions(-) create mode 100644 pkg/workflow/awf_config_build.go create mode 100644 pkg/workflow/awf_config_policy.go create mode 100644 pkg/workflow/awf_config_schema.go diff --git a/.github/skills/awf-release-integrator/SKILL.md b/.github/skills/awf-release-integrator/SKILL.md index eeb39ac7712..bdd4d847a6a 100644 --- a/.github/skills/awf-release-integrator/SKILL.md +++ b/.github/skills/awf-release-integrator/SKILL.md @@ -21,6 +21,9 @@ Consult these sources before editing anything: 4. The embedded AWF schema in `pkg/workflow/schemas/awf-config.schema.json`. 5. AWF config integration code in: - `pkg/workflow/awf_config.go` + - `pkg/workflow/awf_config_build.go` + - `pkg/workflow/awf_config_schema.go` + - `pkg/workflow/awf_config_policy.go` - `pkg/workflow/awf_helpers.go` - related AWF tests under `pkg/workflow/` diff --git a/pkg/workflow/README.md b/pkg/workflow/README.md index 379646064b8..29c3c8e98be 100644 --- a/pkg/workflow/README.md +++ b/pkg/workflow/README.md @@ -1173,7 +1173,7 @@ This appendix is generated from the current non-test Go source files in this pac | `artifact_manager.go` | `(*ArtifactManager).Reset` | `func (*ArtifactManager).Reset()` | Reset clears all tracked uploads and downloads | | `artifact_manager.go` | `NewArtifactManager` | `func NewArtifactManager() *ArtifactManager` | NewArtifactManager creates a new artifact manager | | `auto_update_workflow.go` | `GenerateAutoUpdateWorkflow` | `func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error` | GenerateAutoUpdateWorkflow generates or removes the agentic-auto-upgrade. | -| `awf_config.go` | `BuildAWFConfigJSON` | `func BuildAWFConfigJSON(config AWFCommandConfig) (string, error)` | BuildAWFConfigJSON generates a compact JSON config file for AWF from the provided command configuration. | +| `awf_config_build.go` | `BuildAWFConfigJSON` | `func BuildAWFConfigJSON(config AWFCommandConfig) (string, error)` | BuildAWFConfigJSON generates a compact JSON config file for AWF from the provided command configuration. | | `behavior_defined_engine.go` | `(*BehaviorDefinedEngine).GetAgentManifestFiles` | `func (*BehaviorDefinedEngine).GetAgentManifestFiles() []string` | Exported function or method declared in `behavior_defined_engine.go`. | | `behavior_defined_engine.go` | `(*BehaviorDefinedEngine).GetAgentManifestPathPrefixes` | `func (*BehaviorDefinedEngine).GetAgentManifestPathPrefixes() []string` | Exported function or method declared in `behavior_defined_engine.go`. | | `behavior_defined_engine.go` | `(*BehaviorDefinedEngine).GetModelEnvVarName` | `func (*BehaviorDefinedEngine).GetModelEnvVarName() string` | Exported function or method declared in `behavior_defined_engine.go`. | diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c1f9e1b0faa..0482c38ae3a 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -64,89 +64,11 @@ package workflow import ( - _ "embed" - "encoding/json" - "fmt" - "maps" - "slices" - "strconv" - "strings" - "time" - - "github.com/santhosh-tekuri/jsonschema/v6" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/jsonutil" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/semverutil" - "github.com/github/gh-aw/pkg/setutil" - "github.com/github/gh-aw/pkg/syncutil" - "github.com/github/gh-aw/pkg/workflow/compilerenv" ) -//go:embed schemas/awf-config.schema.json -var awfConfigSchema string - var awfConfigLog = logger.New("workflow:awf_config") -// Cached compiled AWF config schema to avoid recompiling on every validation. -var compiledAWFConfigSchemaLoader syncutil.OnceLoader[*jsonschema.Schema] - -// getCompiledAWFConfigSchema returns the compiled AWF config schema, compiling once and caching. -func getCompiledAWFConfigSchema() (*jsonschema.Schema, error) { - return compiledAWFConfigSchemaLoader.Get(func() (*jsonschema.Schema, error) { - awfConfigLog.Print("Compiling AWF config schema (first time)") - schemaURL := fmt.Sprintf("https://github.com/github/gh-aw-firewall/releases/download/%s/awf-config.schema.json", constants.DefaultFirewallVersion) - schema, err := compileSchema(awfConfigSchema, schemaURL) - if err == nil { - awfConfigLog.Print("AWF config schema compiled successfully") - } - return schema, err - }) -} - -// validateAWFConfigJSON validates the provided AWF config JSON string against the -// embedded AWF config schema. Returns nil if validation passes. -func validateAWFConfigJSON(configJSON string) error { - schema, err := getCompiledAWFConfigSchema() - if err != nil { - return err - } - var doc any - if err := json.Unmarshal([]byte(configJSON), &doc); err != nil { - return fmt.Errorf("invalid AWF config JSON: expected generated output to be valid JSON for schema validation; parse error: %w. This indicates a compiler bug; please report it", err) - } - normalizeTemplatableModelFallbackEnabled(doc) - if err := schema.Validate(doc); err != nil { - return fmt.Errorf("invalid AWF config JSON: expected generated output to satisfy the embedded schema; review the referenced field path and fix that workflow/frontmatter value: %w", err) - } - return nil -} - -// normalizeTemplatableModelFallbackEnabled adjusts a generated AWF config document -// for compile-time schema validation by coercing modelFallback.enabled GitHub Actions -// expressions to a boolean placeholder. GitHub Actions resolves these expressions at -// runtime before AWF consumes the config. -func normalizeTemplatableModelFallbackEnabled(doc any) { - root, ok := doc.(map[string]any) - if !ok { - return - } - apiProxy, ok := root["apiProxy"].(map[string]any) - if !ok { - return - } - modelFallback, ok := apiProxy["modelFallback"].(map[string]any) - if !ok { - return - } - enabled, ok := modelFallback["enabled"].(string) - if !ok || !isExpression(enabled) { - return - } - modelFallback["enabled"] = true -} - // AWFConfigFile represents the AWF configuration file schema. // This is the top-level structure written to awf-config.json. type AWFConfigFile struct { @@ -453,638 +375,3 @@ type AWFChrootIdentityConfig struct { // Home is the home directory path to export inside chroot mode. Home string `json:"home,omitempty"` } - -// buildAWFConfigSchemaURL returns the release-pinned JSON schema URL for the AWF config file. -// The URL is versioned so that schema validation tools always reference the exact schema -// that matches the AWF binary being used. When DefaultFirewallVersion is bumped the URL -// automatically tracks the new release. -// -// If firewallConfig carries an explicit version (e.g. sandbox.agent.version) that version -// is used; otherwise DefaultFirewallVersion is used. -func buildAWFConfigSchemaURL(firewallConfig *FirewallConfig) string { - version := string(constants.DefaultFirewallVersion) - if firewallConfig != nil && firewallConfig.Version != "" { - version = firewallConfig.Version - } - // Special-case "latest": the GitHub Releases /latest/download/ shortcut serves - // assets from the most recent release without requiring a tag in the path. - if strings.EqualFold(version, "latest") { - return "https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json" - } - // Ensure version has the 'v' prefix required by GitHub release tag URLs. - version = semverutil.EnsureVPrefix(version) - return fmt.Sprintf("https://github.com/github/gh-aw-firewall/releases/download/%s/awf-config.schema.json", version) -} - -// BuildAWFConfigJSON generates a compact JSON config file for AWF from the provided -// command configuration. The JSON is single-line (no indentation) for safe embedding -// in a shell printf command. -// -// The caller is responsible for writing the returned JSON to disk at the path expected -// by the AWF --config flag. See BuildAWFCommand for how this is wired together. -func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { - awfConfigLog.Printf("Building AWF config JSON: engine=%s, allowed_domains=%q", config.EngineName, config.AllowedDomains) - - // Resolve firewall config once — used for both the schema URL and the container image tag. - firewallConfig := getFirewallConfig(config.WorkflowData) - - awfConfig := AWFConfigFile{ - Schema: buildAWFConfigSchemaURL(firewallConfig), - } - if config.WorkflowData != nil { - awfConfig.Enclaves = buildAWFEnclavesConfig(config.WorkflowData.Enclaves) - } - - // ── Runner section ────────────────────────────────────────────────────── - if topology := getRunnerTopology(config.WorkflowData); topology != "" { - awfConfig.Runner = &AWFRunnerConfig{Topology: string(topology)} - awfConfigLog.Printf("Runner section: topology=%s", topology) - } - - // ── Network section ────────────────────────────────────────────────────── - if config.AllowedDomains != "" { - allowList := splitDomainList(config.AllowedDomains) - awfConfig.Network = &AWFNetworkConfig{ - AllowDomains: allowList, - } - awfConfigLog.Printf("Network section: %d allowed domains", len(allowList)) - - // Blocked domains (if configured in the workflow) - if config.WorkflowData != nil { - blockedDomainsStr := formatBlockedDomains(config.WorkflowData.NetworkPermissions) - if blockedDomainsStr != "" { - blockList := splitDomainList(blockedDomainsStr) - awfConfig.Network.BlockDomains = blockList - awfConfigLog.Printf("Network section: %d blocked domains", len(blockList)) - } - } - } - - if isAWFNetworkIsolationEnabled(config.WorkflowData) { - if awfConfig.Network == nil { - awfConfig.Network = &AWFNetworkConfig{} - } - awfConfig.Network.Isolation = true - awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData) - awfConfigLog.Printf("Network section: isolation enabled with %d topology attachments", len(awfConfig.Network.TopologyAttach)) - } - - // Docker sbx microVMs resolve host services via - // host.docker.internal - // (the Docker bridge gateway, 172.17.0.1). Allow this domain so AWF's network - // policy permits connections from the microVM to the api-proxy, MCP gateway, and - // Squid proxy that are all published on the host bridge. - if isDockerSbxRuntime(config.WorkflowData) { - if awfConfig.Network == nil { - awfConfig.Network = &AWFNetworkConfig{} - } - const hostDockerInternal = "host.docker.internal" - if !slices.Contains(awfConfig.Network.AllowDomains, hostDockerInternal) { - awfConfig.Network.AllowDomains = append(awfConfig.Network.AllowDomains, hostDockerInternal) - awfConfigLog.Printf("Network section: added %s for microVM runtime routing", hostDockerInternal) - } - } - - // ── Filesystem section ─────────────────────────────────────────────────── - if config.WorkflowData != nil && - config.WorkflowData.SandboxConfig != nil && - config.WorkflowData.SandboxConfig.Agent != nil && - config.WorkflowData.SandboxConfig.Agent.Config != nil && - config.WorkflowData.SandboxConfig.Agent.Config.Filesystem != nil && - config.WorkflowData.SandboxConfig.Agent.Config.Filesystem.AllowWrite != nil { - allowWrite := config.WorkflowData.SandboxConfig.Agent.Config.Filesystem.AllowWrite - if awfEmitsFilesystemAllowWrite(config.WorkflowData, firewallConfig) { - awfConfig.Filesystem = &AWFFilesystemConfig{AllowWrite: allowWrite} - awfConfigLog.Printf("Filesystem section: %d writable path(s)", len(allowWrite)) - } else if isCloudHypervisorRuntime(config.WorkflowData) { - awfConfigLog.Printf("Skipping filesystem.allowWrite: AWF version %q requires at least %s for the cloud-hypervisor runtime", - getAWFImageTag(firewallConfig), constants.AWFCloudHypervisorFilesystemAllowWriteMinVersion) - } else { - awfConfigLog.Print("Skipping filesystem.allowWrite: only the cloud-hypervisor runtime enforces it without breaking the agent container") - } - } - - if platformType := extractPlatformType(config.WorkflowData); platformType != "" { - awfConfig.Platform = &AWFPlatformConfig{Type: platformType} - awfConfigLog.Printf("Platform section: type=%s", platformType) - } - - // ── API proxy section ───────────────────────────────────────────────────── - // maxAICredits is taken from frontmatter/imports only; when unset (0) the - // runtime value is resolved from vars.GH_AW_DEFAULT_MAX_AI_CREDITS via a - // GitHub Actions expression injected directly into the JSON string in - // BuildAWFCommand (see injectMaxAICreditsExpression in awf_helpers.go). - maxAICredits := int64(0) - maxRuns := constants.DefaultMaxRuns - // GetMaxTurnCacheMisses handles nil receiver and env-var fallback, so pre-init - // via the nil receiver avoids a redundant os.Getenv when EngineConfig is set. - maxTurnCacheMisses := (*EngineConfig)(nil).GetMaxTurnCacheMisses() - if config.WorkflowData != nil && config.WorkflowData.EngineConfig != nil { - if config.WorkflowData.EngineConfig.MaxAICredits != 0 { - maxAICredits = config.WorkflowData.EngineConfig.MaxAICredits - } - maxRuns = config.WorkflowData.EngineConfig.GetMaxRuns() - maxTurnCacheMisses = config.WorkflowData.EngineConfig.GetMaxTurnCacheMisses() - } - - // Token steering is enabled by default. Setting max-ai-credits to a negative - // value (-1) omits that budget from the AWF config and disables token steering. - // When maxAICredits is 0 (runtime default), token steering stays enabled here. - enableTokenSteering := maxAICredits >= 0 - if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil { - enableTokenSteering = *config.WorkflowData.SandboxConfig.Agent.TokenSteering - } - if maxAICredits < 0 { - // Negative signals "disabled" — omit the budget from the AWF config. - maxAICredits = 0 - } - var tokenSteeringEnabled *bool - if awfSupportsTokenSteering(firewallConfig) && (enableTokenSteering || (config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil)) { - tokenSteeringEnabled = &enableTokenSteering - } - - apiProxy := &AWFAPIProxyConfig{ - Enabled: true, - MaxRuns: maxRuns, - MaxTurnCacheMisses: maxTurnCacheMisses, - MaxAICredits: maxAICredits, - EnableTokenSteering: tokenSteeringEnabled, - } - - if !enableTokenSteering { - awfConfigLog.Print("Disabling apiProxy.enableTokenSteering") - } else if !awfSupportsTokenSteering(firewallConfig) { - awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFTokenSteeringMinVersion) - } - - if mf := extractModelFallback(config.WorkflowData); mf != nil { - apiProxy.ModelFallback = mf - enabledDisplay := "" - if mf.Enabled != nil { - enabledDisplay = mf.Enabled.String() - } - awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s", enabledDisplay) - } else if hasCustomLLMAPITarget(config.WorkflowData) { - // Custom OpenAI/Anthropic-compatible providers (e.g. OpenRouter, internal LLM - // routers, Azure OpenAI) expose model identifiers that are absent from the - // built-in AWF model catalog. Letting AWF rewrite the requested model then - // yields HTTP 404 model_not_found upstream, so pass the configured model - // through verbatim unless the workflow explicitly opts back in. - disabled := TemplatableBool("false") - apiProxy.ModelFallback = &AWFModelFallbackConfig{Enabled: &disabled} - awfConfigLog.Print("API proxy: modelFallback disabled by default: custom LLM API target configured") - } - - if pricing := extractDefaultAiCreditsPricing(config.WorkflowData); pricing != nil { - apiProxy.DefaultAiCreditsPricing = pricing - awfConfigLog.Printf("API proxy: defaultAiCreditsPricing configured: input=%g, output=%g", pricing.Input, pricing.Output) - } - - targets := map[string]*AWFAPITargetConfig{} - - if openaiTarget := extractAPITargetHost(config.WorkflowData, "OPENAI_BASE_URL"); openaiTarget != "" { - targets["openai"] = &AWFAPITargetConfig{Host: openaiTarget} - awfConfigLog.Printf("API proxy: custom openai target=%s", openaiTarget) - } - if anthropicTarget := extractAPITargetHost(config.WorkflowData, "ANTHROPIC_BASE_URL"); anthropicTarget != "" { - targets["anthropic"] = &AWFAPITargetConfig{Host: anthropicTarget} - awfConfigLog.Printf("API proxy: custom anthropic target=%s", anthropicTarget) - } - - // Apply authHeader overrides from sandbox.agent.targets frontmatter. - // These are independent of the host/env-var settings: authHeader can be set - // even when no custom host is configured. - for _, provider := range []string{"openai", "anthropic"} { - authHeader := extractAPITargetAuthHeader(config.WorkflowData, provider) - if authHeader == "" { - continue - } - if existing, ok := targets[provider]; ok { - existing.AuthHeader = authHeader - } else { - targets[provider] = &AWFAPITargetConfig{AuthHeader: authHeader} - } - awfConfigLog.Printf("API proxy: custom %s authHeader=%s", provider, authHeader) - } - if copilotTarget := GetCopilotAPITarget(config.WorkflowData); copilotTarget != "" { - targets["copilot"] = &AWFAPITargetConfig{Host: copilotTarget} - awfConfigLog.Printf("API proxy: custom copilot target=%s", copilotTarget) - } - - // Apply BYOK supplemental fields from sandbox.agent.targets.copilot frontmatter. - // extraHeaders, extraBodyFields, and sessionId are Copilot-specific and map to - // AWF_BYOK_EXTRA_HEADERS, AWF_BYOK_EXTRA_BODY_FIELDS, and AWF_PROVIDER_SESSION_ID. - if copilotFrontmatter := extractCopilotTargetConfig(config.WorkflowData); copilotFrontmatter != nil { - existing, ok := targets["copilot"] - if !ok { - existing = &AWFAPITargetConfig{} - targets["copilot"] = existing - } - if copilotFrontmatter.AuthHeader != "" { - existing.AuthHeader = copilotFrontmatter.AuthHeader - awfConfigLog.Printf("API proxy: copilot authHeader=%s", copilotFrontmatter.AuthHeader) - } - if len(copilotFrontmatter.ExtraHeaders) > 0 { - existing.ExtraHeaders = copilotFrontmatter.ExtraHeaders - awfConfigLog.Printf("API proxy: copilot extraHeaders configured (%d header(s))", len(copilotFrontmatter.ExtraHeaders)) - } - if len(copilotFrontmatter.ExtraBodyFields) > 0 { - existing.ExtraBodyFields = copilotFrontmatter.ExtraBodyFields - awfConfigLog.Printf("API proxy: copilot extraBodyFields configured (%d field(s))", len(copilotFrontmatter.ExtraBodyFields)) - } - if copilotFrontmatter.SessionId != "" { - existing.SessionId = copilotFrontmatter.SessionId - awfConfigLog.Printf("API proxy: copilot sessionId configured") - } - } - if geminiTarget := GetGeminiAPITarget(config.WorkflowData, config.EngineName); geminiTarget != "" { - awfConfigLog.Printf("API proxy: custom gemini target=%s", geminiTarget) - targets["gemini"] = &AWFAPITargetConfig{Host: geminiTarget} - } - - if len(targets) > 0 { - apiProxy.Targets = targets - awfConfigLog.Printf("API proxy: %d custom targets configured", len(targets)) - } - - if providers := extractModelCostProviders(config.WorkflowData); len(providers) > 0 { - if awfSupportsAPIProxyProviders(firewallConfig) { - apiProxy.Providers = providers - awfConfigLog.Printf("API proxy: %d model-cost provider override(s) configured", len(providers)) - } else { - awfConfigLog.Printf("Skipping apiProxy.providers: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAPIProxyProvidersMinVersion) - } - } - - // ── Models section (nested under apiProxy per AWF config schema) ────────── - if config.WorkflowData != nil && len(config.WorkflowData.ModelMappings) > 0 { - apiProxy.Models = config.WorkflowData.ModelMappings - awfConfigLog.Printf("Models section: %d alias entries", len(config.WorkflowData.ModelMappings)) - } - allowedModels, disallowedModels := resolveModelPolicyForAWFConfig(config.WorkflowData) - if len(allowedModels) > 0 { - apiProxy.AllowedModels = allowedModels - awfConfigLog.Printf("Models policy: %d allowed model pattern(s)", len(allowedModels)) - } - if len(disallowedModels) > 0 { - apiProxy.DisallowedModels = disallowedModels - awfConfigLog.Printf("Models policy: %d disallowed model pattern(s)", len(disallowedModels)) - } - - awfConfig.APIProxy = apiProxy - - // ── Container section ───────────────────────────────────────────────────── - awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData) - // A custom image manifest (sandbox.agent.images) is a closed set of digest-pinned - // references. AWF rejects it alongside imageTag, which would select a different - // effective image, so the compiler-owned tag is suppressed when it is configured. - containerImages := getSandboxAgentImages(config.WorkflowData) - if len(containerImages) > 0 { - awfImageTag = "" - } - agentRuntime := getAgentContainerRuntime(config.WorkflowData) - agentTimeout := 0 - if isDockerSbxRuntime(config.WorkflowData) || isCloudHypervisorRuntime(config.WorkflowData) { - agentTimeout = resolveAWFContainerAgentTimeoutMinutes(config.WorkflowData) - } - // containerRuntime is only emitted when the effective AWF version supports it. - // Gate here to avoid sending an unrecognised field to older AWF binaries. - if !awfSupportsContainerRuntime(firewallConfig) { - if agentRuntime != "" { - awfConfigLog.Printf("Skipping containerRuntime: AWF version %q requires at least %s (gh-aw-firewall#6093)", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) - } - agentRuntime = "" - } - if awfImageTag != "" || isArcDindTopology(config.WorkflowData) || agentRuntime != "" || agentTimeout > 0 || len(containerImages) > 0 { - container := &AWFContainerConfig{ - ImageTag: awfImageTag, - AgentTimeout: agentTimeout, - ContainerRuntime: agentRuntime, - Images: containerImages, - } - // NOTE: dockerHostPathPrefix is intentionally NOT set for arc-dind topology. - // With sysroot-stage active, the Docker daemon can access all needed paths: - // - Workspace & RUNNER_TEMP: on the shared work volume (/home/runner/_work/) - // - System binaries: provided by the sysroot named volume (not bind mounts) - // - Kernel VFS (/dev, /sys): daemon's own kernel - // Setting a prefix would incorrectly translate the workspace mount source to - // a non-existent path (e.g. /prefix/home/runner/_work/repo → empty dir), - // causing the agent to see an empty workspace. See gh-aw#34896. - awfConfig.Container = container - if awfImageTag != "" { - awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag) - } - if agentRuntime != "" { - awfConfigLog.Printf("Container section: containerRuntime=%s", agentRuntime) - } - if agentTimeout > 0 { - awfConfigLog.Printf("Container section: agentTimeout=%d", agentTimeout) - } - if len(containerImages) > 0 { - awfConfigLog.Printf("Container section: custom image manifest with %d role(s)", len(containerImages)) - } - } - - // ── Logging section ────────────────────────────────────────────────────── - // Logging paths are set in config. For ARC/DinD, the config file is written at runtime, - // so ${RUNNER_TEMP} can be preserved for shell expansion before AWF reads the JSON. - awfConfig.Logging = &AWFLoggingConfig{ - ProxyLogsDir: string(constants.AWFProxyLogsDir), - AuditDir: string(constants.AWFAuditDir), - } - if isArcDindTopology(config.WorkflowData) { - awfConfig.Logging.ProxyLogsDir = awfArcDindProxyLogsDirExpr - awfConfig.Logging.AuditDir = awfArcDindAuditDirExpr - } - awfConfigLog.Printf("Logging section: proxyLogsDir=%s, auditDir=%s", awfConfig.Logging.ProxyLogsDir, awfConfig.Logging.AuditDir) - - // ── Bounded queries section ────────────────────────────────────────────── - if bq := extractBoundedQueriesConfig(config.WorkflowData); bq != nil { - if awfSupportsBoundedQueries(firewallConfig) { - awfConfig.BoundedQueries = bq - awfConfigLog.Printf("Bounded queries section: %d private repo(s)", len(bq.PrivateRepos)) - } else { - awfConfigLog.Printf("Skipping boundedQueries: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFBoundedQueriesMinVersion) - } - } - - jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig) - if err != nil { - return "", fmt.Errorf("invalid AWF config values: expected generated output to be JSON-serializable; encountered serialization error: %w. This indicates a compiler bug; please report it", err) - } - - awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonStr)) - - if config.WorkflowData != nil && config.WorkflowData.ValidateAWFConfig { - if err := validateAWFConfigJSON(jsonStr); err != nil { - return "", fmt.Errorf("invalid generated AWF config: expected awf-config JSON to satisfy the embedded schema; review the referenced field path and fix that workflow/frontmatter value: %w", err) - } - } - - return jsonStr, nil -} - -func resolveAWFContainerAgentTimeoutMinutes(workflowData *WorkflowData) int { - // Reuse the workflow-level default timeout so docker-sbx inherits the same - // runtime ceiling when top-level timeout-minutes is omitted or non-numeric. - defaultTimeout := compilerenv.ResolveDefaultTimeoutMinutes(int(constants.DefaultAgenticWorkflowTimeout / time.Minute)) - if workflowData == nil || workflowData.TimeoutMinutes == "" { - return defaultTimeout - } - - rawTimeout := strings.TrimSpace(workflowData.TimeoutMinutes) - if after, ok := strings.CutPrefix(rawTimeout, "timeout-minutes:"); ok { - rawTimeout = strings.TrimSpace(after) - } - - timeoutMinutes, err := strconv.Atoi(rawTimeout) - if err == nil && timeoutMinutes > 0 { - return timeoutMinutes - } - - if rawTimeout != "" { - // agentTimeout is integer-only, so an expression-backed timeout (e.g. the - // vars.GH_AW_DEFAULT_TIMEOUT_MINUTES default) cannot be emitted here. - // Omitting it keeps the sandbox bounded by the step/job timeout instead of - // terminating the agent earlier than the runtime value requests. - awfConfigLog.Printf("Container section: non-numeric timeout-minutes %q (e.g. a GitHub Actions expression) cannot be emitted in integer-only agentTimeout; omitting agentTimeout so the step timeout governs", rawTimeout) - return 0 - } - return defaultTimeout -} - -// buildAWFTopologyAttachList returns container names that AWF should attach to -// the internal awf-net network when network isolation mode is enabled. -// The list always includes the MCP gateway and conditionally includes the -// host-started CLI proxy sidecar when gh-proxy mode is active. Cloud Hypervisor -// omits the CLI proxy until its control peer supports the proxy's TCP port. -func buildAWFTopologyAttachList(workflowData *WorkflowData) []string { - targets := []string{"awmg-mcpg"} - if !isCloudHypervisorRuntime(workflowData) && isCliProxyNeeded(workflowData) { - targets = append(targets, "awmg-cli-proxy") - } - return targets -} - -// splitDomainList splits a comma-separated domain string into a deduplicated -// slice. Empty entries are ignored. The order of the original list is preserved for -// non-duplicate entries; this keeps the allow-list deterministic. -func splitDomainList(domains string) []string { - var result []string - seen := make(map[string]struct { - }) - for d := range strings.SplitSeq(domains, ",") { - d = strings.TrimSpace(d) - if d != "" && !setutil.Contains(seen, d) { - seen[d] = struct { - }{} - result = append(result, d) - } - } - return result -} - -// resolveModelPolicyForAWFConfig applies policy precedence independently per list: -// allowed rules are narrowed using intersection with env policy, while blocked -// rules are widened using union with env policy. -func resolveModelPolicyForAWFConfig(workflowData *WorkflowData) ([]string, []string) { - envAllowed, hasAllowedOverride := compilerenv.ResolvePolicyModelsAllowed() - envBlocked, hasBlockedOverride := compilerenv.ResolvePolicyModelsBlocked() - var allowed []string - var blocked []string - if workflowData != nil { - allowed = workflowData.ModelPolicyAllowed - blocked = workflowData.ModelPolicyBlocked - } - if hasAllowedOverride { - allowed = intersectModelPolicyRules(allowed, envAllowed) - } - if hasBlockedOverride { - blocked = unionModelPolicyRules(blocked, envBlocked) - } - blockedSet := make(map[string]struct{}, len(blocked)) - for _, model := range blocked { - blockedSet[model] = struct{}{} - } - allowed = filterAllowedModelConflictsWithSet(allowed, blockedSet) - return allowed, blocked -} - -func intersectModelPolicyRules(local, override []string) []string { - if len(override) == 0 { - return append([]string(nil), local...) - } - // No local allow-list means no workflow restriction; keep the env allow-list. - if len(local) == 0 { - return append([]string(nil), override...) - } - localSet := make(map[string]struct{}, len(local)) - for _, model := range local { - localSet[model] = struct{}{} - } - result := make([]string, 0, len(override)) - for _, model := range override { - if _, ok := localSet[model]; ok { - result = append(result, model) - } - } - return result -} - -func unionModelPolicyRules(local, override []string) []string { - result := make([]string, 0, len(local)+len(override)) - seen := make(map[string]struct{}, len(local)+len(override)) - for _, model := range local { - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - result = append(result, model) - } - for _, model := range override { - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - result = append(result, model) - } - return result -} - -// extractPlatformType returns sandbox.agent.platform only for enabled AWF sandbox -// agents, or an empty string to let AWF fall back to its default platform logic. -func extractPlatformType(workflowData *WorkflowData) string { - if workflowData == nil || workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { - return "" - } - if workflowData.SandboxConfig.Agent.Disabled { - return "" - } - if !isSupportedSandboxType(getAgentType(workflowData.SandboxConfig.Agent)) { - return "" - } - return workflowData.SandboxConfig.Agent.Platform -} - -// extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured -// sandbox.agent.model-fallback, or nil if the field is absent (letting AWF use its default). -func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { - if workflowData == nil { - return nil - } - if workflowData.SandboxConfig == nil { - return nil - } - if workflowData.SandboxConfig.Agent == nil { - return nil - } - mf := workflowData.SandboxConfig.Agent.ModelFallback - if mf == nil { - return nil - } - return &AWFModelFallbackConfig{ - Enabled: mf, - } -} - -// hasCustomLLMAPITarget reports whether the workflow routes the agentic engine to a -// custom OpenAI-compatible or Anthropic-compatible provider through an engine.env base -// URL (OPENAI_BASE_URL / ANTHROPIC_BASE_URL). Such providers (OpenRouter, internal LLM -// routers, Azure OpenAI deployments) use model identifiers that are not present in the -// AWF built-in model catalog. -func hasCustomLLMAPITarget(workflowData *WorkflowData) bool { - for _, envVar := range []string{"OPENAI_BASE_URL", "ANTHROPIC_BASE_URL"} { - if engineEnvHasNonEmptyValue(workflowData, envVar) { - return true - } - } - return false -} - -// extractDefaultAiCreditsPricing returns an AiCreditsPricingConfig if the workflow has -// configured models.default-ai-credits-pricing, or nil if the field is absent. -// This fallback pricing is used when maxAiCredits is active and the requested model is not in -// the built-in pricing table, preventing HTTP 400 unknown_model_ai_credits for BYOK/self-hosted models. -func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AiCreditsPricingConfig { - if workflowData == nil { - return nil - } - p := workflowData.DefaultAiCreditsPricing - if p == nil { - return nil - } - return &AiCreditsPricingConfig{ - Input: p.Input, - Output: p.Output, - CachedInput: p.CachedInput, - CacheWrite: p.CacheWrite, - } -} - -func extractModelCostProviders(workflowData *WorkflowData) map[string]any { - if workflowData == nil || len(workflowData.ModelCosts) == 0 { - return nil - } - providers, ok := workflowData.ModelCosts["providers"].(map[string]any) - if !ok { - awfConfigLog.Printf("API proxy: models.providers has unexpected type %T; skipping provider overlay", workflowData.ModelCosts["providers"]) - return nil - } - if len(providers) == 0 { - return nil - } - clone := make(map[string]any, len(providers)) - maps.Copy(clone, providers) - return clone -} - -// extractBoundedQueriesConfig returns an AWFBoundedQueriesConfig populated from -// tools.github.bounded-queries, or nil when the field is absent. -// Only fields explicitly set in frontmatter are included; optional fields that -// were not specified are omitted so that AWF remains the source of truth for defaults. -func extractBoundedQueriesConfig(workflowData *WorkflowData) *AWFBoundedQueriesConfig { - if workflowData == nil { - return nil - } - if workflowData.ParsedTools == nil || workflowData.ParsedTools.GitHub == nil { - return nil - } - bq := workflowData.ParsedTools.GitHub.BoundedQueries - if bq == nil { - return nil - } - - awfBQ := &AWFBoundedQueriesConfig{ - Enabled: true, - Runtime: bq.Runtime, - MemoryLimit: bq.MemoryLimit, - Interpreter: bq.Interpreter, - } - awfBQ.Timeout = bq.Timeout - if bq.MaxInvocations != nil { - awfBQ.MaxInvocations = *bq.MaxInvocations - } - - for _, r := range bq.PrivateRepos { - awfBQ.PrivateRepos = append(awfBQ.PrivateRepos, &AWFBoundedQueryPrivateRepo{ - Repo: r.Repo, - Sensitivity: r.Sensitivity, - }) - } - - return awfBQ -} - -// getRunnerTopology extracts the runner topology from WorkflowData. -// Returns an empty string when no topology is configured. -func getRunnerTopology(workflowData *WorkflowData) RunnerTopology { - if workflowData == nil || workflowData.RunnerConfig == nil { - return "" - } - return workflowData.RunnerConfig.Topology -} - -// isArcDindTopology returns true when the workflow targets ARC/DinD runners. -func isArcDindTopology(workflowData *WorkflowData) bool { - return getRunnerTopology(workflowData) == RunnerTopologyArcDind -} diff --git a/pkg/workflow/awf_config_build.go b/pkg/workflow/awf_config_build.go new file mode 100644 index 00000000000..1fa138dfcb2 --- /dev/null +++ b/pkg/workflow/awf_config_build.go @@ -0,0 +1,546 @@ +// This file builds the AWF configuration file JSON from workflow data. +// See awf_config.go for the config file types, awf_config_schema.go for schema +// validation, and awf_config_policy.go for model policy and domain resolution. + +package workflow + +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/jsonutil" + "github.com/github/gh-aw/pkg/workflow/compilerenv" +) + +// BuildAWFConfigJSON generates a compact JSON config file for AWF from the provided +// command configuration. The JSON is single-line (no indentation) for safe embedding +// in a shell printf command. +// +// The caller is responsible for writing the returned JSON to disk at the path expected +// by the AWF --config flag. See BuildAWFCommand for how this is wired together. +func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { + awfConfigLog.Printf("Building AWF config JSON: engine=%s, allowed_domains=%q", config.EngineName, config.AllowedDomains) + + // Resolve firewall config once — used for both the schema URL and the container image tag. + firewallConfig := getFirewallConfig(config.WorkflowData) + + awfConfig := AWFConfigFile{ + Schema: buildAWFConfigSchemaURL(firewallConfig), + } + if config.WorkflowData != nil { + awfConfig.Enclaves = buildAWFEnclavesConfig(config.WorkflowData.Enclaves) + } + + // ── Runner section ────────────────────────────────────────────────────── + if topology := getRunnerTopology(config.WorkflowData); topology != "" { + awfConfig.Runner = &AWFRunnerConfig{Topology: string(topology)} + awfConfigLog.Printf("Runner section: topology=%s", topology) + } + + // ── Network section ────────────────────────────────────────────────────── + if config.AllowedDomains != "" { + allowList := splitDomainList(config.AllowedDomains) + awfConfig.Network = &AWFNetworkConfig{ + AllowDomains: allowList, + } + awfConfigLog.Printf("Network section: %d allowed domains", len(allowList)) + + // Blocked domains (if configured in the workflow) + if config.WorkflowData != nil { + blockedDomainsStr := formatBlockedDomains(config.WorkflowData.NetworkPermissions) + if blockedDomainsStr != "" { + blockList := splitDomainList(blockedDomainsStr) + awfConfig.Network.BlockDomains = blockList + awfConfigLog.Printf("Network section: %d blocked domains", len(blockList)) + } + } + } + + if isAWFNetworkIsolationEnabled(config.WorkflowData) { + if awfConfig.Network == nil { + awfConfig.Network = &AWFNetworkConfig{} + } + awfConfig.Network.Isolation = true + awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData) + awfConfigLog.Printf("Network section: isolation enabled with %d topology attachments", len(awfConfig.Network.TopologyAttach)) + } + + // Docker sbx microVMs resolve host services via + // host.docker.internal + // (the Docker bridge gateway, 172.17.0.1). Allow this domain so AWF's network + // policy permits connections from the microVM to the api-proxy, MCP gateway, and + // Squid proxy that are all published on the host bridge. + if isDockerSbxRuntime(config.WorkflowData) { + if awfConfig.Network == nil { + awfConfig.Network = &AWFNetworkConfig{} + } + const hostDockerInternal = "host.docker.internal" + if !slices.Contains(awfConfig.Network.AllowDomains, hostDockerInternal) { + awfConfig.Network.AllowDomains = append(awfConfig.Network.AllowDomains, hostDockerInternal) + awfConfigLog.Printf("Network section: added %s for microVM runtime routing", hostDockerInternal) + } + } + + // ── Filesystem section ─────────────────────────────────────────────────── + if config.WorkflowData != nil && + config.WorkflowData.SandboxConfig != nil && + config.WorkflowData.SandboxConfig.Agent != nil && + config.WorkflowData.SandboxConfig.Agent.Config != nil && + config.WorkflowData.SandboxConfig.Agent.Config.Filesystem != nil && + config.WorkflowData.SandboxConfig.Agent.Config.Filesystem.AllowWrite != nil { + allowWrite := config.WorkflowData.SandboxConfig.Agent.Config.Filesystem.AllowWrite + if awfEmitsFilesystemAllowWrite(config.WorkflowData, firewallConfig) { + awfConfig.Filesystem = &AWFFilesystemConfig{AllowWrite: allowWrite} + awfConfigLog.Printf("Filesystem section: %d writable path(s)", len(allowWrite)) + } else if isCloudHypervisorRuntime(config.WorkflowData) { + awfConfigLog.Printf("Skipping filesystem.allowWrite: AWF version %q requires at least %s for the cloud-hypervisor runtime", + getAWFImageTag(firewallConfig), constants.AWFCloudHypervisorFilesystemAllowWriteMinVersion) + } else { + awfConfigLog.Print("Skipping filesystem.allowWrite: only the cloud-hypervisor runtime enforces it without breaking the agent container") + } + } + + if platformType := extractPlatformType(config.WorkflowData); platformType != "" { + awfConfig.Platform = &AWFPlatformConfig{Type: platformType} + awfConfigLog.Printf("Platform section: type=%s", platformType) + } + + // ── API proxy section ───────────────────────────────────────────────────── + // maxAICredits is taken from frontmatter/imports only; when unset (0) the + // runtime value is resolved from vars.GH_AW_DEFAULT_MAX_AI_CREDITS via a + // GitHub Actions expression injected directly into the JSON string in + // BuildAWFCommand (see injectMaxAICreditsExpression in awf_helpers.go). + maxAICredits := int64(0) + maxRuns := constants.DefaultMaxRuns + // GetMaxTurnCacheMisses handles nil receiver and env-var fallback, so pre-init + // via the nil receiver avoids a redundant os.Getenv when EngineConfig is set. + maxTurnCacheMisses := (*EngineConfig)(nil).GetMaxTurnCacheMisses() + if config.WorkflowData != nil && config.WorkflowData.EngineConfig != nil { + if config.WorkflowData.EngineConfig.MaxAICredits != 0 { + maxAICredits = config.WorkflowData.EngineConfig.MaxAICredits + } + maxRuns = config.WorkflowData.EngineConfig.GetMaxRuns() + maxTurnCacheMisses = config.WorkflowData.EngineConfig.GetMaxTurnCacheMisses() + } + + // Token steering is enabled by default. Setting max-ai-credits to a negative + // value (-1) omits that budget from the AWF config and disables token steering. + // When maxAICredits is 0 (runtime default), token steering stays enabled here. + enableTokenSteering := maxAICredits >= 0 + if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil { + enableTokenSteering = *config.WorkflowData.SandboxConfig.Agent.TokenSteering + } + if maxAICredits < 0 { + // Negative signals "disabled" — omit the budget from the AWF config. + maxAICredits = 0 + } + var tokenSteeringEnabled *bool + if awfSupportsTokenSteering(firewallConfig) && (enableTokenSteering || (config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil)) { + tokenSteeringEnabled = &enableTokenSteering + } + + apiProxy := &AWFAPIProxyConfig{ + Enabled: true, + MaxRuns: maxRuns, + MaxTurnCacheMisses: maxTurnCacheMisses, + MaxAICredits: maxAICredits, + EnableTokenSteering: tokenSteeringEnabled, + } + + if !enableTokenSteering { + awfConfigLog.Print("Disabling apiProxy.enableTokenSteering") + } else if !awfSupportsTokenSteering(firewallConfig) { + awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFTokenSteeringMinVersion) + } + + if mf := extractModelFallback(config.WorkflowData); mf != nil { + apiProxy.ModelFallback = mf + enabledDisplay := "" + if mf.Enabled != nil { + enabledDisplay = mf.Enabled.String() + } + awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s", enabledDisplay) + } else if hasCustomLLMAPITarget(config.WorkflowData) { + // Custom OpenAI/Anthropic-compatible providers (e.g. OpenRouter, internal LLM + // routers, Azure OpenAI) expose model identifiers that are absent from the + // built-in AWF model catalog. Letting AWF rewrite the requested model then + // yields HTTP 404 model_not_found upstream, so pass the configured model + // through verbatim unless the workflow explicitly opts back in. + disabled := TemplatableBool("false") + apiProxy.ModelFallback = &AWFModelFallbackConfig{Enabled: &disabled} + awfConfigLog.Print("API proxy: modelFallback disabled by default: custom LLM API target configured") + } + + if pricing := extractDefaultAiCreditsPricing(config.WorkflowData); pricing != nil { + apiProxy.DefaultAiCreditsPricing = pricing + awfConfigLog.Printf("API proxy: defaultAiCreditsPricing configured: input=%g, output=%g", pricing.Input, pricing.Output) + } + + targets := map[string]*AWFAPITargetConfig{} + + if openaiTarget := extractAPITargetHost(config.WorkflowData, "OPENAI_BASE_URL"); openaiTarget != "" { + targets["openai"] = &AWFAPITargetConfig{Host: openaiTarget} + awfConfigLog.Printf("API proxy: custom openai target=%s", openaiTarget) + } + if anthropicTarget := extractAPITargetHost(config.WorkflowData, "ANTHROPIC_BASE_URL"); anthropicTarget != "" { + targets["anthropic"] = &AWFAPITargetConfig{Host: anthropicTarget} + awfConfigLog.Printf("API proxy: custom anthropic target=%s", anthropicTarget) + } + + // Apply authHeader overrides from sandbox.agent.targets frontmatter. + // These are independent of the host/env-var settings: authHeader can be set + // even when no custom host is configured. + for _, provider := range []string{"openai", "anthropic"} { + authHeader := extractAPITargetAuthHeader(config.WorkflowData, provider) + if authHeader == "" { + continue + } + if existing, ok := targets[provider]; ok { + existing.AuthHeader = authHeader + } else { + targets[provider] = &AWFAPITargetConfig{AuthHeader: authHeader} + } + awfConfigLog.Printf("API proxy: custom %s authHeader=%s", provider, authHeader) + } + if copilotTarget := GetCopilotAPITarget(config.WorkflowData); copilotTarget != "" { + targets["copilot"] = &AWFAPITargetConfig{Host: copilotTarget} + awfConfigLog.Printf("API proxy: custom copilot target=%s", copilotTarget) + } + + // Apply BYOK supplemental fields from sandbox.agent.targets.copilot frontmatter. + // extraHeaders, extraBodyFields, and sessionId are Copilot-specific and map to + // AWF_BYOK_EXTRA_HEADERS, AWF_BYOK_EXTRA_BODY_FIELDS, and AWF_PROVIDER_SESSION_ID. + if copilotFrontmatter := extractCopilotTargetConfig(config.WorkflowData); copilotFrontmatter != nil { + existing, ok := targets["copilot"] + if !ok { + existing = &AWFAPITargetConfig{} + targets["copilot"] = existing + } + if copilotFrontmatter.AuthHeader != "" { + existing.AuthHeader = copilotFrontmatter.AuthHeader + awfConfigLog.Printf("API proxy: copilot authHeader=%s", copilotFrontmatter.AuthHeader) + } + if len(copilotFrontmatter.ExtraHeaders) > 0 { + existing.ExtraHeaders = copilotFrontmatter.ExtraHeaders + awfConfigLog.Printf("API proxy: copilot extraHeaders configured (%d header(s))", len(copilotFrontmatter.ExtraHeaders)) + } + if len(copilotFrontmatter.ExtraBodyFields) > 0 { + existing.ExtraBodyFields = copilotFrontmatter.ExtraBodyFields + awfConfigLog.Printf("API proxy: copilot extraBodyFields configured (%d field(s))", len(copilotFrontmatter.ExtraBodyFields)) + } + if copilotFrontmatter.SessionId != "" { + existing.SessionId = copilotFrontmatter.SessionId + awfConfigLog.Printf("API proxy: copilot sessionId configured") + } + } + if geminiTarget := GetGeminiAPITarget(config.WorkflowData, config.EngineName); geminiTarget != "" { + awfConfigLog.Printf("API proxy: custom gemini target=%s", geminiTarget) + targets["gemini"] = &AWFAPITargetConfig{Host: geminiTarget} + } + + if len(targets) > 0 { + apiProxy.Targets = targets + awfConfigLog.Printf("API proxy: %d custom targets configured", len(targets)) + } + + if providers := extractModelCostProviders(config.WorkflowData); len(providers) > 0 { + if awfSupportsAPIProxyProviders(firewallConfig) { + apiProxy.Providers = providers + awfConfigLog.Printf("API proxy: %d model-cost provider override(s) configured", len(providers)) + } else { + awfConfigLog.Printf("Skipping apiProxy.providers: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAPIProxyProvidersMinVersion) + } + } + + // ── Models section (nested under apiProxy per AWF config schema) ────────── + if config.WorkflowData != nil && len(config.WorkflowData.ModelMappings) > 0 { + apiProxy.Models = config.WorkflowData.ModelMappings + awfConfigLog.Printf("Models section: %d alias entries", len(config.WorkflowData.ModelMappings)) + } + allowedModels, disallowedModels := resolveModelPolicyForAWFConfig(config.WorkflowData) + if len(allowedModels) > 0 { + apiProxy.AllowedModels = allowedModels + awfConfigLog.Printf("Models policy: %d allowed model pattern(s)", len(allowedModels)) + } + if len(disallowedModels) > 0 { + apiProxy.DisallowedModels = disallowedModels + awfConfigLog.Printf("Models policy: %d disallowed model pattern(s)", len(disallowedModels)) + } + + awfConfig.APIProxy = apiProxy + + // ── Container section ───────────────────────────────────────────────────── + awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData) + // A custom image manifest (sandbox.agent.images) is a closed set of digest-pinned + // references. AWF rejects it alongside imageTag, which would select a different + // effective image, so the compiler-owned tag is suppressed when it is configured. + containerImages := getSandboxAgentImages(config.WorkflowData) + if len(containerImages) > 0 { + awfImageTag = "" + } + agentRuntime := getAgentContainerRuntime(config.WorkflowData) + agentTimeout := 0 + if isDockerSbxRuntime(config.WorkflowData) || isCloudHypervisorRuntime(config.WorkflowData) { + agentTimeout = resolveAWFContainerAgentTimeoutMinutes(config.WorkflowData) + } + // containerRuntime is only emitted when the effective AWF version supports it. + // Gate here to avoid sending an unrecognised field to older AWF binaries. + if !awfSupportsContainerRuntime(firewallConfig) { + if agentRuntime != "" { + awfConfigLog.Printf("Skipping containerRuntime: AWF version %q requires at least %s (gh-aw-firewall#6093)", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) + } + agentRuntime = "" + } + if awfImageTag != "" || isArcDindTopology(config.WorkflowData) || agentRuntime != "" || agentTimeout > 0 || len(containerImages) > 0 { + container := &AWFContainerConfig{ + ImageTag: awfImageTag, + AgentTimeout: agentTimeout, + ContainerRuntime: agentRuntime, + Images: containerImages, + } + // NOTE: dockerHostPathPrefix is intentionally NOT set for arc-dind topology. + // With sysroot-stage active, the Docker daemon can access all needed paths: + // - Workspace & RUNNER_TEMP: on the shared work volume (/home/runner/_work/) + // - System binaries: provided by the sysroot named volume (not bind mounts) + // - Kernel VFS (/dev, /sys): daemon's own kernel + // Setting a prefix would incorrectly translate the workspace mount source to + // a non-existent path (e.g. /prefix/home/runner/_work/repo → empty dir), + // causing the agent to see an empty workspace. See gh-aw#34896. + awfConfig.Container = container + if awfImageTag != "" { + awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag) + } + if agentRuntime != "" { + awfConfigLog.Printf("Container section: containerRuntime=%s", agentRuntime) + } + if agentTimeout > 0 { + awfConfigLog.Printf("Container section: agentTimeout=%d", agentTimeout) + } + if len(containerImages) > 0 { + awfConfigLog.Printf("Container section: custom image manifest with %d role(s)", len(containerImages)) + } + } + + // ── Logging section ────────────────────────────────────────────────────── + // Logging paths are set in config. For ARC/DinD, the config file is written at runtime, + // so ${RUNNER_TEMP} can be preserved for shell expansion before AWF reads the JSON. + awfConfig.Logging = &AWFLoggingConfig{ + ProxyLogsDir: string(constants.AWFProxyLogsDir), + AuditDir: string(constants.AWFAuditDir), + } + if isArcDindTopology(config.WorkflowData) { + awfConfig.Logging.ProxyLogsDir = awfArcDindProxyLogsDirExpr + awfConfig.Logging.AuditDir = awfArcDindAuditDirExpr + } + awfConfigLog.Printf("Logging section: proxyLogsDir=%s, auditDir=%s", awfConfig.Logging.ProxyLogsDir, awfConfig.Logging.AuditDir) + + // ── Bounded queries section ────────────────────────────────────────────── + if bq := extractBoundedQueriesConfig(config.WorkflowData); bq != nil { + if awfSupportsBoundedQueries(firewallConfig) { + awfConfig.BoundedQueries = bq + awfConfigLog.Printf("Bounded queries section: %d private repo(s)", len(bq.PrivateRepos)) + } else { + awfConfigLog.Printf("Skipping boundedQueries: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFBoundedQueriesMinVersion) + } + } + + jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig) + if err != nil { + return "", fmt.Errorf("invalid AWF config values: expected generated output to be JSON-serializable; encountered serialization error: %w. This indicates a compiler bug; please report it", err) + } + + awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonStr)) + + if config.WorkflowData != nil && config.WorkflowData.ValidateAWFConfig { + if err := validateAWFConfigJSON(jsonStr); err != nil { + return "", fmt.Errorf("invalid generated AWF config: expected awf-config JSON to satisfy the embedded schema; review the referenced field path and fix that workflow/frontmatter value: %w", err) + } + } + + return jsonStr, nil +} + +func resolveAWFContainerAgentTimeoutMinutes(workflowData *WorkflowData) int { + // Reuse the workflow-level default timeout so docker-sbx inherits the same + // runtime ceiling when top-level timeout-minutes is omitted or non-numeric. + defaultTimeout := compilerenv.ResolveDefaultTimeoutMinutes(int(constants.DefaultAgenticWorkflowTimeout / time.Minute)) + if workflowData == nil || workflowData.TimeoutMinutes == "" { + return defaultTimeout + } + + rawTimeout := strings.TrimSpace(workflowData.TimeoutMinutes) + if after, ok := strings.CutPrefix(rawTimeout, "timeout-minutes:"); ok { + rawTimeout = strings.TrimSpace(after) + } + + timeoutMinutes, err := strconv.Atoi(rawTimeout) + if err == nil && timeoutMinutes > 0 { + return timeoutMinutes + } + + if rawTimeout != "" { + // agentTimeout is integer-only, so an expression-backed timeout (e.g. the + // vars.GH_AW_DEFAULT_TIMEOUT_MINUTES default) cannot be emitted here. + // Omitting it keeps the sandbox bounded by the step/job timeout instead of + // terminating the agent earlier than the runtime value requests. + awfConfigLog.Printf("Container section: non-numeric timeout-minutes %q (e.g. a GitHub Actions expression) cannot be emitted in integer-only agentTimeout; omitting agentTimeout so the step timeout governs", rawTimeout) + return 0 + } + return defaultTimeout +} + +// buildAWFTopologyAttachList returns container names that AWF should attach to +// the internal awf-net network when network isolation mode is enabled. +// The list always includes the MCP gateway and conditionally includes the +// host-started CLI proxy sidecar when gh-proxy mode is active. Cloud Hypervisor +// omits the CLI proxy until its control peer supports the proxy's TCP port. +func buildAWFTopologyAttachList(workflowData *WorkflowData) []string { + targets := []string{"awmg-mcpg"} + if !isCloudHypervisorRuntime(workflowData) && isCliProxyNeeded(workflowData) { + targets = append(targets, "awmg-cli-proxy") + } + return targets +} + +// extractPlatformType returns sandbox.agent.platform only for enabled AWF sandbox +// agents, or an empty string to let AWF fall back to its default platform logic. +func extractPlatformType(workflowData *WorkflowData) string { + if workflowData == nil || workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { + return "" + } + if workflowData.SandboxConfig.Agent.Disabled { + return "" + } + if !isSupportedSandboxType(getAgentType(workflowData.SandboxConfig.Agent)) { + return "" + } + return workflowData.SandboxConfig.Agent.Platform +} + +// extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured +// sandbox.agent.model-fallback, or nil if the field is absent (letting AWF use its default). +func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { + if workflowData == nil { + return nil + } + if workflowData.SandboxConfig == nil { + return nil + } + if workflowData.SandboxConfig.Agent == nil { + return nil + } + mf := workflowData.SandboxConfig.Agent.ModelFallback + if mf == nil { + return nil + } + return &AWFModelFallbackConfig{ + Enabled: mf, + } +} + +// hasCustomLLMAPITarget reports whether the workflow routes the agentic engine to a +// custom OpenAI-compatible or Anthropic-compatible provider through an engine.env base +// URL (OPENAI_BASE_URL / ANTHROPIC_BASE_URL). Such providers (OpenRouter, internal LLM +// routers, Azure OpenAI deployments) use model identifiers that are not present in the +// AWF built-in model catalog. +func hasCustomLLMAPITarget(workflowData *WorkflowData) bool { + for _, envVar := range []string{"OPENAI_BASE_URL", "ANTHROPIC_BASE_URL"} { + if engineEnvHasNonEmptyValue(workflowData, envVar) { + return true + } + } + return false +} + +// extractDefaultAiCreditsPricing returns an AiCreditsPricingConfig if the workflow has +// configured models.default-ai-credits-pricing, or nil if the field is absent. +// This fallback pricing is used when maxAiCredits is active and the requested model is not in +// the built-in pricing table, preventing HTTP 400 unknown_model_ai_credits for BYOK/self-hosted models. +func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AiCreditsPricingConfig { + if workflowData == nil { + return nil + } + p := workflowData.DefaultAiCreditsPricing + if p == nil { + return nil + } + return &AiCreditsPricingConfig{ + Input: p.Input, + Output: p.Output, + CachedInput: p.CachedInput, + CacheWrite: p.CacheWrite, + } +} + +func extractModelCostProviders(workflowData *WorkflowData) map[string]any { + if workflowData == nil || len(workflowData.ModelCosts) == 0 { + return nil + } + providers, ok := workflowData.ModelCosts["providers"].(map[string]any) + if !ok { + awfConfigLog.Printf("API proxy: models.providers has unexpected type %T; skipping provider overlay", workflowData.ModelCosts["providers"]) + return nil + } + if len(providers) == 0 { + return nil + } + clone := make(map[string]any, len(providers)) + maps.Copy(clone, providers) + return clone +} + +// extractBoundedQueriesConfig returns an AWFBoundedQueriesConfig populated from +// tools.github.bounded-queries, or nil when the field is absent. +// Only fields explicitly set in frontmatter are included; optional fields that +// were not specified are omitted so that AWF remains the source of truth for defaults. +func extractBoundedQueriesConfig(workflowData *WorkflowData) *AWFBoundedQueriesConfig { + if workflowData == nil { + return nil + } + if workflowData.ParsedTools == nil || workflowData.ParsedTools.GitHub == nil { + return nil + } + bq := workflowData.ParsedTools.GitHub.BoundedQueries + if bq == nil { + return nil + } + + awfBQ := &AWFBoundedQueriesConfig{ + Enabled: true, + Runtime: bq.Runtime, + MemoryLimit: bq.MemoryLimit, + Interpreter: bq.Interpreter, + } + awfBQ.Timeout = bq.Timeout + if bq.MaxInvocations != nil { + awfBQ.MaxInvocations = *bq.MaxInvocations + } + + for _, r := range bq.PrivateRepos { + awfBQ.PrivateRepos = append(awfBQ.PrivateRepos, &AWFBoundedQueryPrivateRepo{ + Repo: r.Repo, + Sensitivity: r.Sensitivity, + }) + } + + return awfBQ +} + +// getRunnerTopology extracts the runner topology from WorkflowData. +// Returns an empty string when no topology is configured. +func getRunnerTopology(workflowData *WorkflowData) RunnerTopology { + if workflowData == nil || workflowData.RunnerConfig == nil { + return "" + } + return workflowData.RunnerConfig.Topology +} + +// isArcDindTopology returns true when the workflow targets ARC/DinD runners. +func isArcDindTopology(workflowData *WorkflowData) bool { + return getRunnerTopology(workflowData) == RunnerTopologyArcDind +} diff --git a/pkg/workflow/awf_config_policy.go b/pkg/workflow/awf_config_policy.go new file mode 100644 index 00000000000..bc8f6c862c6 --- /dev/null +++ b/pkg/workflow/awf_config_policy.go @@ -0,0 +1,96 @@ +// This file resolves model policy rules and domain lists used by the AWF +// configuration file. See awf_config_build.go for how these values are applied. + +package workflow + +import ( + "strings" + + "github.com/github/gh-aw/pkg/setutil" + "github.com/github/gh-aw/pkg/workflow/compilerenv" +) + +// splitDomainList splits a comma-separated domain string into a deduplicated +// slice. Empty entries are ignored. The order of the original list is preserved for +// non-duplicate entries; this keeps the allow-list deterministic. +func splitDomainList(domains string) []string { + var result []string + seen := make(map[string]struct { + }) + for d := range strings.SplitSeq(domains, ",") { + d = strings.TrimSpace(d) + if d != "" && !setutil.Contains(seen, d) { + seen[d] = struct { + }{} + result = append(result, d) + } + } + return result +} + +// resolveModelPolicyForAWFConfig applies policy precedence independently per list: +// allowed rules are narrowed using intersection with env policy, while blocked +// rules are widened using union with env policy. +func resolveModelPolicyForAWFConfig(workflowData *WorkflowData) ([]string, []string) { + envAllowed, hasAllowedOverride := compilerenv.ResolvePolicyModelsAllowed() + envBlocked, hasBlockedOverride := compilerenv.ResolvePolicyModelsBlocked() + var allowed []string + var blocked []string + if workflowData != nil { + allowed = workflowData.ModelPolicyAllowed + blocked = workflowData.ModelPolicyBlocked + } + if hasAllowedOverride { + allowed = intersectModelPolicyRules(allowed, envAllowed) + } + if hasBlockedOverride { + blocked = unionModelPolicyRules(blocked, envBlocked) + } + blockedSet := make(map[string]struct{}, len(blocked)) + for _, model := range blocked { + blockedSet[model] = struct{}{} + } + allowed = filterAllowedModelConflictsWithSet(allowed, blockedSet) + return allowed, blocked +} + +func intersectModelPolicyRules(local, override []string) []string { + if len(override) == 0 { + return append([]string(nil), local...) + } + // No local allow-list means no workflow restriction; keep the env allow-list. + if len(local) == 0 { + return append([]string(nil), override...) + } + localSet := make(map[string]struct{}, len(local)) + for _, model := range local { + localSet[model] = struct{}{} + } + result := make([]string, 0, len(override)) + for _, model := range override { + if _, ok := localSet[model]; ok { + result = append(result, model) + } + } + return result +} + +func unionModelPolicyRules(local, override []string) []string { + result := make([]string, 0, len(local)+len(override)) + seen := make(map[string]struct{}, len(local)+len(override)) + for _, model := range local { + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + for _, model := range override { + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + return result +} diff --git a/pkg/workflow/awf_config_schema.go b/pkg/workflow/awf_config_schema.go new file mode 100644 index 00000000000..260b27fc9cd --- /dev/null +++ b/pkg/workflow/awf_config_schema.go @@ -0,0 +1,101 @@ +// This file provides schema validation for generated AWF configuration files. +// See awf_config.go for the config file types and awf_config_build.go for the +// construction of the config JSON that is validated here. + +package workflow + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/semverutil" + "github.com/github/gh-aw/pkg/syncutil" +) + +//go:embed schemas/awf-config.schema.json +var awfConfigSchema string + +// Cached compiled AWF config schema to avoid recompiling on every validation. +var compiledAWFConfigSchemaLoader syncutil.OnceLoader[*jsonschema.Schema] + +// getCompiledAWFConfigSchema returns the compiled AWF config schema, compiling once and caching. +func getCompiledAWFConfigSchema() (*jsonschema.Schema, error) { + return compiledAWFConfigSchemaLoader.Get(func() (*jsonschema.Schema, error) { + awfConfigLog.Print("Compiling AWF config schema (first time)") + schemaURL := fmt.Sprintf("https://github.com/github/gh-aw-firewall/releases/download/%s/awf-config.schema.json", constants.DefaultFirewallVersion) + schema, err := compileSchema(awfConfigSchema, schemaURL) + if err == nil { + awfConfigLog.Print("AWF config schema compiled successfully") + } + return schema, err + }) +} + +// validateAWFConfigJSON validates the provided AWF config JSON string against the +// embedded AWF config schema. Returns nil if validation passes. +func validateAWFConfigJSON(configJSON string) error { + schema, err := getCompiledAWFConfigSchema() + if err != nil { + return err + } + var doc any + if err := json.Unmarshal([]byte(configJSON), &doc); err != nil { + return fmt.Errorf("invalid AWF config JSON: expected generated output to be valid JSON for schema validation; parse error: %w. This indicates a compiler bug; please report it", err) + } + normalizeTemplatableModelFallbackEnabled(doc) + if err := schema.Validate(doc); err != nil { + return fmt.Errorf("invalid AWF config JSON: expected generated output to satisfy the embedded schema; review the referenced field path and fix that workflow/frontmatter value: %w", err) + } + return nil +} + +// normalizeTemplatableModelFallbackEnabled adjusts a generated AWF config document +// for compile-time schema validation by coercing modelFallback.enabled GitHub Actions +// expressions to a boolean placeholder. GitHub Actions resolves these expressions at +// runtime before AWF consumes the config. +func normalizeTemplatableModelFallbackEnabled(doc any) { + root, ok := doc.(map[string]any) + if !ok { + return + } + apiProxy, ok := root["apiProxy"].(map[string]any) + if !ok { + return + } + modelFallback, ok := apiProxy["modelFallback"].(map[string]any) + if !ok { + return + } + enabled, ok := modelFallback["enabled"].(string) + if !ok || !isExpression(enabled) { + return + } + modelFallback["enabled"] = true +} + +// buildAWFConfigSchemaURL returns the release-pinned JSON schema URL for the AWF config file. +// The URL is versioned so that schema validation tools always reference the exact schema +// that matches the AWF binary being used. When DefaultFirewallVersion is bumped the URL +// automatically tracks the new release. +// +// If firewallConfig carries an explicit version (e.g. sandbox.agent.version) that version +// is used; otherwise DefaultFirewallVersion is used. +func buildAWFConfigSchemaURL(firewallConfig *FirewallConfig) string { + version := string(constants.DefaultFirewallVersion) + if firewallConfig != nil && firewallConfig.Version != "" { + version = firewallConfig.Version + } + // Special-case "latest": the GitHub Releases /latest/download/ shortcut serves + // assets from the most recent release without requiring a tag in the path. + if strings.EqualFold(version, "latest") { + return "https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json" + } + // Ensure version has the 'v' prefix required by GitHub release tag URLs. + version = semverutil.EnsureVPrefix(version) + return fmt.Sprintf("https://github.com/github/gh-aw-firewall/releases/download/%s/awf-config.schema.json", version) +} From 8b154def3e0624cbda82c09cfd8ed20cdb452a86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:20:33 +0000 Subject: [PATCH 3/4] Add draft ADR-55496: split awf_config.go into types/schema/build/policy files --- ...f-config-into-types-schema-build-policy.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/adr/55496-split-awf-config-into-types-schema-build-policy.md diff --git a/docs/adr/55496-split-awf-config-into-types-schema-build-policy.md b/docs/adr/55496-split-awf-config-into-types-schema-build-policy.md new file mode 100644 index 00000000000..3186bf493b0 --- /dev/null +++ b/docs/adr/55496-split-awf-config-into-types-schema-build-policy.md @@ -0,0 +1,44 @@ +# ADR-55496: Split awf_config.go into Types, Schema, Build, and Policy Files + +**Date**: 2026-08-24 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +`pkg/workflow/awf_config.go` had grown to 1,090 lines, mixing three distinct concerns: Go type definitions for the AWF configuration file, JSON schema validation, AWF config JSON construction (`BuildAWFConfigJSON`), and model-policy and domain-list resolution. It was the repository's second-largest non-test Go file and was under active churn. The `pkg/workflow` package follows a "one file per functionality" convention (e.g., `awf_helpers.go`, `awf_enclaves.go`), which this monolithic file violated. Reviewers had to scan the entire file to locate the concern they cared about. + +### Decision + +We will split `pkg/workflow/awf_config.go` into four focused files — `awf_config.go` (type definitions), `awf_config_schema.go` (embedded JSON schema, schema compilation/validation, `buildAWFConfigSchemaURL`), `awf_config_build.go` (`BuildAWFConfigJSON` and all build/extract helpers), and `awf_config_policy.go` (`resolveModelPolicyForAWFConfig`, `intersectModelPolicyRules`, `unionModelPolicyRules`, `splitDomainList`) — matching the "one file per functionality" convention already established in `pkg/workflow`. This is a pure code move with no logic changes. + +### Alternatives Considered + +#### Alternative 1: Keep the monolithic file + +Add package-level or function-group comments to orient readers within the single 1,090-line file. This requires no structural change and carries no migration risk, but it does not resolve the difficulty of locating and reviewing specific concerns under ongoing churn. The file would continue to grow as new AWF config sections are added. + +#### Alternative 2: Extract into a separate Go package + +Move AWF config logic into `pkg/workflow/awfconfig` (a new sub-package). This would provide stronger encapsulation and cleaner import boundaries. However, it would require renaming exported types, updating all call sites across the repo, and deciding which types remain in `pkg/workflow` to avoid circular imports — significant cost for what amounts to a readability improvement. + +### Consequences + +#### Positive +- Each file now has a single, clearly named responsibility that matches the `pkg/workflow` "one file per functionality" convention, reducing the mental surface area for reviewers. +- Future changes to schema validation, model policy, or build logic touch only the relevant file, making diffs easier to read and review. +- Each new file carries a short cross-reference header pointing to its siblings, so navigating the split is self-documenting. + +#### Negative +- `BuildAWFConfigJSON` at 339 lines remains un-decomposed inside `awf_config_build.go`; linting still flags it. Decomposing it was explicitly out of scope here to keep this a mechanical, reviewable move. +- Any tooling or documentation that enumerates `awf_config.go` as the single AWF integration file (e.g., skill manifests, README appendices) must now list all four files and must be kept in sync when further files are added. + +#### Neutral +- All four files remain in the same Go package (`package workflow`), so no exported symbols are renamed, no call sites change, and existing tests compile unchanged. +- The AWF release integrator skill (`SKILL.md`) and `pkg/workflow/README.md` were updated in this PR to reflect the new file layout. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 4ff4dda555246ca8b50ba74b2dd027e1b3451181 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:51:38 +0000 Subject: [PATCH 4/4] Scope awf config loggers per file and refresh ADR path references Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- docs/adr/34693-add-antigravity-engine-deprecate-gemini.md | 4 ++-- .../adr/35286-compiler-managed-enterprise-env-controls.md | 8 ++++---- .../35694-expose-authheader-in-awf-apiproxy-targets.md | 2 +- pkg/workflow/awf_config.go | 6 ------ pkg/workflow/awf_config_build.go | 3 +++ pkg/workflow/awf_config_schema.go | 7 +++++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/adr/34693-add-antigravity-engine-deprecate-gemini.md b/docs/adr/34693-add-antigravity-engine-deprecate-gemini.md index 380a120f4f2..3fa7850dd04 100644 --- a/docs/adr/34693-add-antigravity-engine-deprecate-gemini.md +++ b/docs/adr/34693-add-antigravity-engine-deprecate-gemini.md @@ -41,7 +41,7 @@ Mark `gemini` deprecated and remove it on a fixed date / version. Rejected becau #### Negative - Two engines with nearly identical wiring (`antigravity_engine.go` plus `_logs.go`, `_mcp.go`, `_tools.go` mirror the Gemini files) — every future Gemini/Antigravity engine change must be made in two places until Gemini is removed. - Shared port (`AntigravityLLMGatewayPort = 10003 = GeminiLLMGatewayPort`) means a single workflow cannot run both engines concurrently in the same job; this is implicit and not statically enforced today. -- Domain and target aliasing (`GeminiDefaultDomains` → `AntigravityDefaultDomains`, dual keys in `awf_config.go`) is correct now but is a latent footgun: changes to one set must be mirrored to the other or one engine silently diverges. +- Domain and target aliasing (`GeminiDefaultDomains` → `AntigravityDefaultDomains`, dual keys in `awf_config_build.go`) is correct now but is a latent footgun: changes to one set must be mirrored to the other or one engine silently diverges. - Carrying the deprecation warning indefinitely means CI logs for Gemini workflows will accumulate warning noise; there is no end-of-life date in this ADR. #### Neutral @@ -78,7 +78,7 @@ Mark `gemini` deprecated and remove it on a fixed date / version. Rejected becau ### AWF Proxy and Domain Configuration -1. `awf_config.go` **MUST** populate both `antigravity` and `gemini` target keys whenever either engine is in use. +1. `awf_config_build.go` **MUST** populate both `antigravity` and `gemini` target keys whenever either engine is in use. 2. `awf-config.schema.json` **MUST** accept `antigravity` as a valid proxy target key. 3. `GeminiDefaultDomains` **MUST** remain available as an alias of `AntigravityDefaultDomains` (or vice versa) so that existing references in the codebase compile. 4. `GetGeminiAPITarget` and `DefaultGeminiAPITarget` **MUST** remain exported as deprecated aliases and **MUST** return the same values they did before this change. diff --git a/docs/adr/35286-compiler-managed-enterprise-env-controls.md b/docs/adr/35286-compiler-managed-enterprise-env-controls.md index 16f4d297da1..8080e9a0ac1 100644 --- a/docs/adr/35286-compiler-managed-enterprise-env-controls.md +++ b/docs/adr/35286-compiler-managed-enterprise-env-controls.md @@ -20,11 +20,11 @@ We will introduce a dedicated `pkg/workflow/compilerenv` package as the single s #### Alternative 1: Per-engine inline override chain (no shared package) -Keep the existing pattern of inline `fmt.Sprintf` expressions, and add the `GH_AW_DEFAULT_MODEL_*` term in-line at each call site (Claude, Codex, Copilot, `compiler_yaml.go`, `notify_comment.go`, `awf_config.go`). This was rejected because the override chain is a cross-cutting policy: scattering it across N files makes it easy to drift (one site forgetting the default tier), and adding a new enterprise knob would require touching every site again. Centralizing the knowledge in `compilerenv` keeps the override chain consistent and makes future additions a one-file change. +Keep the existing pattern of inline `fmt.Sprintf` expressions, and add the `GH_AW_DEFAULT_MODEL_*` term in-line at each call site (Claude, Codex, Copilot, `compiler_yaml.go`, `notify_comment.go`, `awf_config_build.go`). This was rejected because the override chain is a cross-cutting policy: scattering it across N files makes it easy to drift (one site forgetting the default tier), and adding a new enterprise knob would require touching every site again. Centralizing the knowledge in `compilerenv` keeps the override chain consistent and makes future additions a one-file change. #### Alternative 2: YAML-only enterprise overrides (no Go-side resolver) -Implement the override chain purely as a `vars.*` expression injected into generated workflow YAML, and resolve everything at GitHub Actions runtime. This was rejected because `max-ai-credits` is also consumed at compile time inside the Go binary — `BuildAWFConfigJSON` (`pkg/workflow/awf_config.go`) and `buildConclusionJob` (`pkg/workflow/notify_comment.go`) need the numeric value to emit into the AWF config JSON and into the failure-reporting env block. A YAML-only solution would leave those compile-time paths unable to honor the enterprise default, so `ResolveDefaultMaxEffectiveTokens` (a Go-side `os.Getenv` reader) is required. +Implement the override chain purely as a `vars.*` expression injected into generated workflow YAML, and resolve everything at GitHub Actions runtime. This was rejected because `max-ai-credits` is also consumed at compile time inside the Go binary — `BuildAWFConfigJSON` (`pkg/workflow/awf_config_build.go`) and `buildConclusionJob` (`pkg/workflow/notify_comment.go`) need the numeric value to emit into the AWF config JSON and into the failure-reporting env block. A YAML-only solution would leave those compile-time paths unable to honor the enterprise default, so `ResolveDefaultMaxEffectiveTokens` (a Go-side `os.Getenv` reader) is required. #### Alternative 3: Config-file-based enterprise overrides (e.g. `.gh-aw-enterprise.yml`) @@ -44,7 +44,7 @@ Store enterprise defaults in a checked-in or repo-configured YAML file rather th - All golden test files asserting on the legacy two-tier expression shape had to be regenerated; any out-of-tree consumer that parses the generated env-var expressions will break. #### Neutral -- New package introduces an import edge from `claude_engine.go`, `codex_engine.go`, `copilot_engine_execution.go`, `compiler_yaml.go`, `compiler_yaml_lookups.go`, `awf_config.go`, and `notify_comment.go` into `pkg/workflow/compilerenv`. +- New package introduces an import edge from `claude_engine.go`, `codex_engine.go`, `copilot_engine_execution.go`, `compiler_yaml.go`, `compiler_yaml_lookups.go`, `awf_config_build.go`, and `notify_comment.go` into `pkg/workflow/compilerenv`. - `GH_AW_INFO_MODEL` (run-info metadata) now follows the same override chain as the engine model env vars, so surfaced metadata matches effective model selection. - The `EngineConfig.GetMaxEffectiveTokens()` accessor is bypassed at the two compile-time sites that now go through `ResolveDefaultMaxEffectiveTokens` plus a direct field check on `EngineConfig.MaxEffectiveTokens`; the accessor still exists for callers that don't need the enterprise default tier. @@ -70,7 +70,7 @@ Store enterprise defaults in a checked-in or repo-configured YAML file rather th ### Max-Effective-Tokens Override -1. Compile-time consumers of the AWF `apiProxy.maxEffectiveTokens` default (currently `pkg/workflow/awf_config.go` and `pkg/workflow/notify_comment.go`) **MUST** resolve the default through `compilerenv.ResolveDefaultMaxEffectiveTokens(constants.DefaultMaxEffectiveTokens)`. +1. Compile-time consumers of the AWF `apiProxy.maxEffectiveTokens` default (currently `pkg/workflow/awf_config_build.go` and `pkg/workflow/notify_comment.go`) **MUST** resolve the default through `compilerenv.ResolveDefaultMaxEffectiveTokens(constants.DefaultMaxEffectiveTokens)`. 2. When workflow frontmatter sets `max-effective-tokens` to a non-zero value, that value **MUST** take precedence over the `GH_AW_DEFAULT_MAX_EFFECTIVE_TOKENS` env var override. 3. When `GH_AW_DEFAULT_MAX_EFFECTIVE_TOKENS` is unset, empty, or not parseable as a base-10 `int64`, the resolver **MUST** return the supplied fallback unchanged. 4. The resolver **MUST NOT** panic, log a fatal error, or fail compilation for an invalid value; it **MUST** fall back silently to the supplied default. diff --git a/docs/adr/35694-expose-authheader-in-awf-apiproxy-targets.md b/docs/adr/35694-expose-authheader-in-awf-apiproxy-targets.md index 75ee00c97bc..45c90787ee4 100644 --- a/docs/adr/35694-expose-authheader-in-awf-apiproxy-targets.md +++ b/docs/adr/35694-expose-authheader-in-awf-apiproxy-targets.md @@ -14,7 +14,7 @@ The AWF firewall sidecar (PR #3998) introduced `--openai-api-auth-header` and `- ### Decision -We will expose `authHeader` as a frontmatter field at `sandbox.agent.targets..authHeader` for `provider ∈ {openai, anthropic}`. The new field is read by a dedicated helper `extractAPITargetAuthHeader` (in `pkg/workflow/engine_api_targets.go`) and applied inside `BuildAWFConfigJSON` (in `pkg/workflow/awf_config.go`) by mutating the existing `AWFAPITargetConfig` entry when one is already present, or creating a header-only entry when no host override exists. The field is emitted with `omitempty` so the generated AWF JSON stays clean when it is not configured. The frontmatter path mirrors the AWF JSON config structure 1:1, preserving the drift-tracking guarantee documented in `specs/awf-config-sources-spec.md`. +We will expose `authHeader` as a frontmatter field at `sandbox.agent.targets..authHeader` for `provider ∈ {openai, anthropic}`. The new field is read by a dedicated helper `extractAPITargetAuthHeader` (in `pkg/workflow/engine_api_targets.go`) and applied inside `BuildAWFConfigJSON` (in `pkg/workflow/awf_config_build.go`) by mutating the existing `AWFAPITargetConfig` entry when one is already present, or creating a header-only entry when no host override exists. The field is emitted with `omitempty` so the generated AWF JSON stays clean when it is not configured. The frontmatter path mirrors the AWF JSON config structure 1:1, preserving the drift-tracking guarantee documented in `specs/awf-config-sources-spec.md`. ### Alternatives Considered diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 0482c38ae3a..f5b9838402e 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -63,12 +63,6 @@ package workflow -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var awfConfigLog = logger.New("workflow:awf_config") - // AWFConfigFile represents the AWF configuration file schema. // This is the top-level structure written to awf-config.json. type AWFConfigFile struct { diff --git a/pkg/workflow/awf_config_build.go b/pkg/workflow/awf_config_build.go index 1fa138dfcb2..e5d4d523bc5 100644 --- a/pkg/workflow/awf_config_build.go +++ b/pkg/workflow/awf_config_build.go @@ -14,9 +14,12 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/jsonutil" + "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/workflow/compilerenv" ) +var awfConfigLog = logger.New("workflow:awf_config") + // BuildAWFConfigJSON generates a compact JSON config file for AWF from the provided // command configuration. The JSON is single-line (no indentation) for safe embedding // in a shell printf command. diff --git a/pkg/workflow/awf_config_schema.go b/pkg/workflow/awf_config_schema.go index 260b27fc9cd..0501aef9371 100644 --- a/pkg/workflow/awf_config_schema.go +++ b/pkg/workflow/awf_config_schema.go @@ -13,10 +13,13 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/semverutil" "github.com/github/gh-aw/pkg/syncutil" ) +var awfConfigSchemaLog = logger.New("workflow:awf_config_schema") + //go:embed schemas/awf-config.schema.json var awfConfigSchema string @@ -26,11 +29,11 @@ var compiledAWFConfigSchemaLoader syncutil.OnceLoader[*jsonschema.Schema] // getCompiledAWFConfigSchema returns the compiled AWF config schema, compiling once and caching. func getCompiledAWFConfigSchema() (*jsonschema.Schema, error) { return compiledAWFConfigSchemaLoader.Get(func() (*jsonschema.Schema, error) { - awfConfigLog.Print("Compiling AWF config schema (first time)") + awfConfigSchemaLog.Print("Compiling AWF config schema (first time)") schemaURL := fmt.Sprintf("https://github.com/github/gh-aw-firewall/releases/download/%s/awf-config.schema.json", constants.DefaultFirewallVersion) schema, err := compileSchema(awfConfigSchema, schemaURL) if err == nil { - awfConfigLog.Print("AWF config schema compiled successfully") + awfConfigSchemaLog.Print("AWF config schema compiled successfully") } return schema, err })