From 0b4c7b0d58fc8b16fe5b78cc05e6d6259644d266 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 04:39:57 +0000 Subject: [PATCH 01/10] Initial plan From 04900dcb5f645614f3567908d12163591543b552 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 04:55:56 +0000 Subject: [PATCH 02/10] feat: expose engine.firewall.apiProxy.modelFallback in compiler frontmatter Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...se-api-proxy-model-fallback-frontmatter.md | 16 ++ .../docs/reference/frontmatter-full.md | 25 +++ pkg/workflow/awf_config.go | 39 ++++ pkg/workflow/awf_config_test.go | 131 ++++++++++++ pkg/workflow/engine.go | 84 ++++++++ pkg/workflow/engine_test.go | 194 ++++++++++++++++++ specs/awf-config-sources-spec.md | 1 + 7 files changed, 490 insertions(+) create mode 100644 .changeset/minor-expose-api-proxy-model-fallback-frontmatter.md diff --git a/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md new file mode 100644 index 00000000000..ff9b0daf6c1 --- /dev/null +++ b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md @@ -0,0 +1,16 @@ +--- +"gh-aw": minor +--- + +Expose `engine.firewall.apiProxy.modelFallback` in the compiler frontmatter so BYOK Azure OpenAI users can disable the middle-power fallback strategy that rewrites deployment names and causes HTTP 404 `DeploymentNotFound` errors. + +Example usage: + +```yaml +engine: + id: copilot + firewall: + apiProxy: + modelFallback: + enabled: false +``` diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index fe1c42eec28..5b60cdb4ab1 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2212,6 +2212,31 @@ engine: # (optional) cache-write: 1 + # AWF firewall sidecar configuration overrides for the engine. These settings are + # written into the inline awf-config.json embedded in the generated lock file. + # (optional) + firewall: + # API proxy sidecar configuration overrides. + # (optional) + apiProxy: + # Model fallback policy for unresolved model selections. AWF default is enabled + # with the middle_power strategy, which silently rewrites deployment names to + # base-catalog names. Set enabled: false for BYOK Azure OpenAI deployments to + # prevent deployment-name rewriting that causes HTTP 404 DeploymentNotFound + # errors. + # (optional) + modelFallback: + # Enable or disable the middle-power fallback when model resolution fails. + # Defaults to true (AWF default). Set to false for custom-provider or BYOK + # Azure deployments where deployment-name rewriting is undesired. + # (optional) + enabled: true + + # Fallback selection strategy. Currently only "middle_power" is supported. + # Omit to use the AWF default. + # (optional) + strategy: "middle_power" + # Optional array of command-line arguments to pass to the AI engine CLI. These # arguments are injected after all other args but before the prompt. # (optional) diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 317b612221c..e177d8e150d 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -163,6 +163,12 @@ type AWFAPIProxyConfig struct { // MaxEffectiveTokens is the explicit ET budget enforced by the API proxy. MaxEffectiveTokens int64 `json:"maxEffectiveTokens,omitempty"` + // ModelFallback configures the model fallback policy for unresolved model selections. + // When nil, the AWF default (enabled=true, strategy=middle_power) is used. + // Set enabled=false to prevent AWF from silently rewriting deployment names, which + // is needed for BYOK Azure OpenAI deployments where rewriting causes HTTP 404. + ModelFallback *AWFModelFallbackConfig `json:"modelFallback,omitempty"` + // ModelMultipliers configures per-model ET accounting multipliers in AWF. ModelMultipliers map[string]float64 `json:"modelMultipliers,omitempty"` @@ -179,6 +185,19 @@ type AWFAPIProxyConfig struct { Models map[string][]string `json:"models,omitempty"` } +// AWFModelFallbackConfig is the "apiProxy.modelFallback" section of the AWF config file. +// It controls the model fallback policy for unresolved model selections. +type AWFModelFallbackConfig struct { + // Enabled controls whether middle-power fallback is applied when model resolution fails. + // AWF default is true. Set to false to disable for BYOK Azure / custom-provider deployments + // where deployment name rewriting causes HTTP 404 DeploymentNotFound errors. + Enabled bool `json:"enabled"` + + // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. + // When omitted, AWF uses its default strategy. + Strategy string `json:"strategy,omitempty"` +} + // AWFAPITargetConfig is a single API proxy target entry. // Maps to: ---api-target type AWFAPITargetConfig struct { @@ -290,6 +309,11 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfigLog.Printf("API proxy: %d model multipliers configured", len(apiProxy.ModelMultipliers)) } + if mf := extractModelFallback(config.WorkflowData); mf != nil { + apiProxy.ModelFallback = mf + awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%v, strategy=%q", mf.Enabled, mf.Strategy) + } + targets := map[string]*AWFAPITargetConfig{} if openaiTarget := extractAPITargetHost(config.WorkflowData, "OPENAI_BASE_URL"); openaiTarget != "" { @@ -381,3 +405,18 @@ func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { } return workflowData.EngineConfig.TokenWeights.Multipliers } + +// extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured +// engine.firewall.apiProxy.modelFallback, or nil if the field is absent (letting AWF use its default). +func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.ModelFallback == nil { + return nil + } + mf := workflowData.EngineConfig.ModelFallback + result := &AWFModelFallbackConfig{} + if mf.Enabled != nil { + result.Enabled = *mf.Enabled + } + result.Strategy = mf.Strategy + return result +} diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 01063d370c2..cca560584e0 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -454,6 +454,99 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, "&&", "JSON output should preserve && in GitHub Actions expressions") assert.NotContains(t, jsonStr, "\\u0026", "JSON output should not HTML-escape '&' characters") }) + + t.Run("modelFallback is emitted when enabled is explicitly set to false", func(t *testing.T) { + falseVal := false + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + ModelFallback: &EngineModelFallbackConfig{ + Enabled: &falseVal, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"modelFallback"`, "apiProxy should emit modelFallback when configured") + assert.Contains(t, jsonStr, `"enabled":false`, "apiProxy.modelFallback.enabled should be false") + assert.NotContains(t, jsonStr, `"strategy"`, "apiProxy.modelFallback should omit strategy when not set") + }) + + t.Run("modelFallback is emitted when enabled is explicitly set to true", func(t *testing.T) { + trueVal := true + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + ModelFallback: &EngineModelFallbackConfig{ + Enabled: &trueVal, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"modelFallback"`, "apiProxy should emit modelFallback when configured") + assert.Contains(t, jsonStr, `"enabled":true`, "apiProxy.modelFallback.enabled should be true") + }) + + t.Run("modelFallback includes strategy when set", func(t *testing.T) { + falseVal := false + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + ModelFallback: &EngineModelFallbackConfig{ + Enabled: &falseVal, + Strategy: "middle_power", + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"modelFallback"`, "apiProxy should emit modelFallback when configured") + assert.Contains(t, jsonStr, `"strategy":"middle_power"`, "apiProxy.modelFallback should include strategy when set") + }) + + t.Run("modelFallback is omitted when not configured in engine", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"modelFallback"`, "apiProxy should omit modelFallback when not configured") + }) } // TestBuildAWFConfigSchemaURL verifies that buildAWFConfigSchemaURL returns a release-pinned @@ -631,6 +724,44 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { }, }, }, + { + name: "config with modelFallback disabled", + config: AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: func() *WorkflowData { + f := false + return &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + ModelFallback: &EngineModelFallbackConfig{Enabled: &f}, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + }(), + }, + }, + { + name: "config with modelFallback disabled and strategy set", + config: AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: func() *WorkflowData { + f := false + return &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + ModelFallback: &EngineModelFallbackConfig{Enabled: &f, Strategy: "middle_power"}, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + }(), + }, + }, } for _, tc := range cases { diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 9034e27f4fd..86d4719e9f3 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -50,6 +50,11 @@ type EngineConfig struct { // When set, overrides or extends the built-in model_multipliers.json values. TokenWeights *types.TokenWeights + // ModelFallback configures the API proxy model fallback policy from engine.firewall.apiProxy.modelFallback. + // When nil, the AWF default (enabled=true, strategy=middle_power) is used. + // Set enabled=false for BYOK Azure OpenAI deployments to prevent deployment name rewriting. + ModelFallback *EngineModelFallbackConfig + // Inline definition fields (populated when engine.runtime is specified in frontmatter) IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic") @@ -72,6 +77,18 @@ type EngineConfig struct { Extensions []string } +// EngineModelFallbackConfig holds model fallback policy parsed from engine.firewall.apiProxy.modelFallback. +// It maps to the apiProxy.modelFallback field in the generated AWF config. +type EngineModelFallbackConfig struct { + // Enabled controls whether the AWF middle-power fallback is applied when model resolution fails. + // A nil pointer means the field was not set in frontmatter; AWF will use its default (true). + Enabled *bool + + // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. + // When empty, AWF uses its default strategy. + Strategy string +} + // EngineAuthConfig represents engine.auth frontmatter settings that map to // AWF_AUTH_* environment variables consumed by the AWF API proxy sidecar. type EngineAuthConfig struct { @@ -487,6 +504,24 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } + // Extract optional 'firewall' sub-object (engine-level AWF config overrides) + if firewallVal, hasFirewall := engineObj["firewall"]; hasFirewall { + if firewallObj, ok := firewallVal.(map[string]any); ok { + if mf := parseEngineModelFallback(firewallObj); mf != nil { + config.ModelFallback = mf + enabled := "" + if mf.Enabled != nil { + if *mf.Enabled { + enabled = "true" + } else { + enabled = "false" + } + } + engineLog.Printf("Extracted engine.firewall.apiProxy.modelFallback: enabled=%s, strategy=%q", enabled, mf.Strategy) + } + } + } + // Return the ID as the engineSetting for backwards compatibility config.MaxRuns = topLevelMaxRuns config.MaxEffectiveTokens = topLevelMaxEffectiveTokens @@ -756,3 +791,52 @@ func parseEngineTokenWeights(raw any) *types.TokenWeights { } return tw } + +// parseEngineModelFallback parses the engine.firewall.apiProxy.modelFallback configuration +// from the "firewall" sub-object of the engine configuration map. Returns nil when modelFallback +// is absent or not a recognised object. The caller is responsible for logging the result. +// +// Expected frontmatter structure: +// +// engine: +// firewall: +// apiProxy: +// modelFallback: +// enabled: false +// strategy: middle_power # optional +func parseEngineModelFallback(firewallObj map[string]any) *EngineModelFallbackConfig { + apiProxyRaw, hasAPIProxy := firewallObj["apiProxy"] + if !hasAPIProxy { + return nil + } + apiProxyObj, ok := apiProxyRaw.(map[string]any) + if !ok { + return nil + } + mfRaw, hasMF := apiProxyObj["modelFallback"] + if !hasMF { + return nil + } + mfObj, ok := mfRaw.(map[string]any) + if !ok { + return nil + } + + result := &EngineModelFallbackConfig{} + if enabledRaw, hasEnabled := mfObj["enabled"]; hasEnabled { + if enabledBool, ok := enabledRaw.(bool); ok { + result.Enabled = &enabledBool + } + } + if strategyRaw, hasStrategy := mfObj["strategy"]; hasStrategy { + if strategyStr, ok := strategyRaw.(string); ok { + result.Strategy = strategyStr + } + } + + // Return nil when neither field was set — treat as absent + if result.Enabled == nil && result.Strategy == "" { + return nil + } + return result +} diff --git a/pkg/workflow/engine_test.go b/pkg/workflow/engine_test.go index 76a1af20995..f9e64a1b67a 100644 --- a/pkg/workflow/engine_test.go +++ b/pkg/workflow/engine_test.go @@ -582,3 +582,197 @@ func TestTokenWeightsEnvOmittedWhenOnlyTokenClassWeightsConfigured(t *testing.T) t.Errorf("Did not expect GH_AW_INFO_TOKEN_WEIGHTS in YAML output when only token-class-weights are configured, got:\n%s", output) } } + +func TestParseEngineModelFallback(t *testing.T) { + t.Run("returns nil when firewall object has no apiProxy key", func(t *testing.T) { + firewallObj := map[string]any{"other": "value"} + got := parseEngineModelFallback(firewallObj) + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("returns nil when apiProxy is not a map", func(t *testing.T) { + firewallObj := map[string]any{"apiProxy": "not-a-map"} + got := parseEngineModelFallback(firewallObj) + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("returns nil when modelFallback is absent", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{"otherField": true}, + } + got := parseEngineModelFallback(firewallObj) + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + }) + + t.Run("returns nil when modelFallback object is empty", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{}, + }, + } + got := parseEngineModelFallback(firewallObj) + if got != nil { + t.Errorf("expected nil for empty modelFallback, got %+v", got) + } + }) + + t.Run("parses enabled=false", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "enabled": false, + }, + }, + } + got := parseEngineModelFallback(firewallObj) + if got == nil { + t.Fatal("expected non-nil result") + } + if got.Enabled == nil { + t.Fatal("expected Enabled to be non-nil") + } + if *got.Enabled != false { + t.Errorf("expected Enabled=false, got %v", *got.Enabled) + } + if got.Strategy != "" { + t.Errorf("expected empty Strategy, got %q", got.Strategy) + } + }) + + t.Run("parses enabled=true", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "enabled": true, + }, + }, + } + got := parseEngineModelFallback(firewallObj) + if got == nil { + t.Fatal("expected non-nil result") + } + if got.Enabled == nil || *got.Enabled != true { + t.Errorf("expected Enabled=true, got %v", got.Enabled) + } + }) + + t.Run("parses strategy field", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "enabled": false, + "strategy": "middle_power", + }, + }, + } + got := parseEngineModelFallback(firewallObj) + if got == nil { + t.Fatal("expected non-nil result") + } + if got.Strategy != "middle_power" { + t.Errorf("expected Strategy=%q, got %q", "middle_power", got.Strategy) + } + }) + + t.Run("returns non-nil when only strategy is set (no enabled)", func(t *testing.T) { + firewallObj := map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "strategy": "middle_power", + }, + }, + } + got := parseEngineModelFallback(firewallObj) + if got == nil { + t.Fatal("expected non-nil result when strategy is set") + } + if got.Enabled != nil { + t.Errorf("expected Enabled=nil, got %v", got.Enabled) + } + if got.Strategy != "middle_power" { + t.Errorf("expected Strategy=%q, got %q", "middle_power", got.Strategy) + } + }) +} + +func TestExtractEngineConfigModelFallback(t *testing.T) { + compiler := NewCompiler() + + t.Run("parses engine.firewall.apiProxy.modelFallback enabled=false", func(t *testing.T) { + frontmatter := map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "firewall": map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "enabled": false, + }, + }, + }, + }, + } + + _, config := compiler.ExtractEngineConfig(frontmatter) + if config == nil { + t.Fatal("Expected non-nil config") + } + if config.ModelFallback == nil { + t.Fatal("Expected ModelFallback to be set") + } + if config.ModelFallback.Enabled == nil { + t.Fatal("Expected ModelFallback.Enabled to be non-nil") + } + if *config.ModelFallback.Enabled != false { + t.Errorf("Expected ModelFallback.Enabled=false, got %v", *config.ModelFallback.Enabled) + } + }) + + t.Run("parses engine.firewall.apiProxy.modelFallback with strategy", func(t *testing.T) { + frontmatter := map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "firewall": map[string]any{ + "apiProxy": map[string]any{ + "modelFallback": map[string]any{ + "enabled": false, + "strategy": "middle_power", + }, + }, + }, + }, + } + + _, config := compiler.ExtractEngineConfig(frontmatter) + if config == nil { + t.Fatal("Expected non-nil config") + } + if config.ModelFallback == nil { + t.Fatal("Expected ModelFallback to be set") + } + if config.ModelFallback.Strategy != "middle_power" { + t.Errorf("Expected Strategy=%q, got %q", "middle_power", config.ModelFallback.Strategy) + } + }) + + t.Run("ModelFallback is nil when engine.firewall is absent", func(t *testing.T) { + frontmatter := map[string]any{ + "engine": map[string]any{ + "id": "copilot", + }, + } + + _, config := compiler.ExtractEngineConfig(frontmatter) + if config == nil { + t.Fatal("Expected non-nil config") + } + if config.ModelFallback != nil { + t.Errorf("Expected ModelFallback to be nil, got %+v", config.ModelFallback) + } + }) +} diff --git a/specs/awf-config-sources-spec.md b/specs/awf-config-sources-spec.md index 11eabdd16fa..b5e1fb4c50e 100644 --- a/specs/awf-config-sources-spec.md +++ b/specs/awf-config-sources-spec.md @@ -58,6 +58,7 @@ The following fields previously existed in schema but were missed in spec CLI ma | `apiProxy.anthropicCacheTailTtl` | `--anthropic-cache-tail-ttl` | | `apiProxy.models` | config-only (model alias rewriting) | | `apiProxy.modelMultipliers` | config-only (effective-token accounting) | +| `apiProxy.modelFallback` | config-only (model fallback policy; set `enabled: false` to prevent deployment-name rewriting for BYOK Azure) | | `apiProxy.maxRuns` | config-only (LLM invocation hard cap) | | `apiProxy.auth.*` | config-only (maps to `AWF_AUTH_*` env vars) | | `container.dockerHostPathPrefix` | `--docker-host-path-prefix` | From 72fc995ac9a15fb70ef07605ca7292ecf5f06c11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 04:58:48 +0000 Subject: [PATCH 03/10] fix: use *bool for AWFModelFallbackConfig.Enabled to avoid false zero-value emission Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/awf_config.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index e177d8e150d..c69a0fdfe40 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -191,7 +191,8 @@ type AWFModelFallbackConfig struct { // Enabled controls whether middle-power fallback is applied when model resolution fails. // AWF default is true. Set to false to disable for BYOK Azure / custom-provider deployments // where deployment name rewriting causes HTTP 404 DeploymentNotFound errors. - Enabled bool `json:"enabled"` + // A nil value omits the field from the generated config, letting AWF use its default. + Enabled *bool `json:"enabled,omitempty"` // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. // When omitted, AWF uses its default strategy. @@ -409,14 +410,18 @@ func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { // extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured // engine.firewall.apiProxy.modelFallback, or nil if the field is absent (letting AWF use its default). func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { - if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.ModelFallback == nil { + if workflowData == nil { + return nil + } + if workflowData.EngineConfig == nil { return nil } mf := workflowData.EngineConfig.ModelFallback - result := &AWFModelFallbackConfig{} - if mf.Enabled != nil { - result.Enabled = *mf.Enabled + if mf == nil { + return nil + } + return &AWFModelFallbackConfig{ + Enabled: mf.Enabled, + Strategy: mf.Strategy, } - result.Strategy = mf.Strategy - return result } From fc773721c1f91cb98038edb0718fb0e4e53779ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 05:30:19 +0000 Subject: [PATCH 04/10] feat: move modelFallback config from engine to sandbox.agent Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/frontmatter-full.md | 42 ++-- pkg/workflow/awf_config.go | 9 +- pkg/workflow/awf_config_test.go | 44 ++-- pkg/workflow/engine.go | 84 -------- pkg/workflow/engine_test.go | 194 ------------------ .../frontmatter_extraction_security.go | 24 +++ .../frontmatter_extraction_security_test.go | 82 ++++++++ pkg/workflow/sandbox.go | 33 ++- 8 files changed, 182 insertions(+), 330 deletions(-) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 5b60cdb4ab1..9d2dff5b8bb 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1862,6 +1862,23 @@ sandbox: # (optional) memory: "example-value" + # Model fallback policy for unresolved model selections. AWF default is enabled + # with the middle_power strategy, which silently rewrites deployment names to + # base-catalog names. Set enabled: false for BYOK Azure OpenAI deployments to + # prevent deployment-name rewriting that causes HTTP 404 DeploymentNotFound errors. + # (optional) + modelFallback: + # Enable or disable the middle-power fallback when model resolution fails. + # Defaults to true (AWF default). Set to false for custom-provider or BYOK + # Azure deployments where deployment-name rewriting is undesired. + # (optional) + enabled: true + + # Fallback selection strategy. Currently only "middle_power" is supported. + # Omit to use the AWF default. + # (optional) + strategy: "middle_power" + # Custom sandbox runtime configuration. Note: Network configuration is controlled # by the top-level 'network' field, not here. # (optional) @@ -2212,31 +2229,6 @@ engine: # (optional) cache-write: 1 - # AWF firewall sidecar configuration overrides for the engine. These settings are - # written into the inline awf-config.json embedded in the generated lock file. - # (optional) - firewall: - # API proxy sidecar configuration overrides. - # (optional) - apiProxy: - # Model fallback policy for unresolved model selections. AWF default is enabled - # with the middle_power strategy, which silently rewrites deployment names to - # base-catalog names. Set enabled: false for BYOK Azure OpenAI deployments to - # prevent deployment-name rewriting that causes HTTP 404 DeploymentNotFound - # errors. - # (optional) - modelFallback: - # Enable or disable the middle-power fallback when model resolution fails. - # Defaults to true (AWF default). Set to false for custom-provider or BYOK - # Azure deployments where deployment-name rewriting is undesired. - # (optional) - enabled: true - - # Fallback selection strategy. Currently only "middle_power" is supported. - # Omit to use the AWF default. - # (optional) - strategy: "middle_power" - # Optional array of command-line arguments to pass to the AI engine CLI. These # arguments are injected after all other args but before the prompt. # (optional) diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c69a0fdfe40..4d616221944 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -408,15 +408,18 @@ func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { } // extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured -// engine.firewall.apiProxy.modelFallback, or nil if the field is absent (letting AWF use its default). +// sandbox.agent.modelFallback, or nil if the field is absent (letting AWF use its default). func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { if workflowData == nil { return nil } - if workflowData.EngineConfig == nil { + if workflowData.SandboxConfig == nil { return nil } - mf := workflowData.EngineConfig.ModelFallback + if workflowData.SandboxConfig.Agent == nil { + return nil + } + mf := workflowData.SandboxConfig.Agent.ModelFallback if mf == nil { return nil } diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index cca560584e0..297c555f0f4 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -463,8 +463,12 @@ func TestBuildAWFConfigJSON(t *testing.T) { WorkflowData: &WorkflowData{ EngineConfig: &EngineConfig{ ID: "copilot", - ModelFallback: &EngineModelFallbackConfig{ - Enabled: &falseVal, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ModelFallback: &SandboxModelFallbackConfig{ + Enabled: &falseVal, + }, }, }, NetworkPermissions: &NetworkPermissions{ @@ -488,8 +492,12 @@ func TestBuildAWFConfigJSON(t *testing.T) { WorkflowData: &WorkflowData{ EngineConfig: &EngineConfig{ ID: "copilot", - ModelFallback: &EngineModelFallbackConfig{ - Enabled: &trueVal, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ModelFallback: &SandboxModelFallbackConfig{ + Enabled: &trueVal, + }, }, }, NetworkPermissions: &NetworkPermissions{ @@ -512,9 +520,13 @@ func TestBuildAWFConfigJSON(t *testing.T) { WorkflowData: &WorkflowData{ EngineConfig: &EngineConfig{ ID: "copilot", - ModelFallback: &EngineModelFallbackConfig{ - Enabled: &falseVal, - Strategy: "middle_power", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ModelFallback: &SandboxModelFallbackConfig{ + Enabled: &falseVal, + Strategy: "middle_power", + }, }, }, NetworkPermissions: &NetworkPermissions{ @@ -529,7 +541,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, `"strategy":"middle_power"`, "apiProxy.modelFallback should include strategy when set") }) - t.Run("modelFallback is omitted when not configured in engine", func(t *testing.T) { + t.Run("modelFallback is omitted when not configured in sandbox", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -732,9 +744,11 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { WorkflowData: func() *WorkflowData { f := false return &WorkflowData{ - EngineConfig: &EngineConfig{ - ID: "copilot", - ModelFallback: &EngineModelFallbackConfig{Enabled: &f}, + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ModelFallback: &SandboxModelFallbackConfig{Enabled: &f}, + }, }, NetworkPermissions: &NetworkPermissions{ Firewall: &FirewallConfig{Enabled: true}, @@ -751,9 +765,11 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { WorkflowData: func() *WorkflowData { f := false return &WorkflowData{ - EngineConfig: &EngineConfig{ - ID: "copilot", - ModelFallback: &EngineModelFallbackConfig{Enabled: &f, Strategy: "middle_power"}, + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ModelFallback: &SandboxModelFallbackConfig{Enabled: &f, Strategy: "middle_power"}, + }, }, NetworkPermissions: &NetworkPermissions{ Firewall: &FirewallConfig{Enabled: true}, diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 86d4719e9f3..9034e27f4fd 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -50,11 +50,6 @@ type EngineConfig struct { // When set, overrides or extends the built-in model_multipliers.json values. TokenWeights *types.TokenWeights - // ModelFallback configures the API proxy model fallback policy from engine.firewall.apiProxy.modelFallback. - // When nil, the AWF default (enabled=true, strategy=middle_power) is used. - // Set enabled=false for BYOK Azure OpenAI deployments to prevent deployment name rewriting. - ModelFallback *EngineModelFallbackConfig - // Inline definition fields (populated when engine.runtime is specified in frontmatter) IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic") @@ -77,18 +72,6 @@ type EngineConfig struct { Extensions []string } -// EngineModelFallbackConfig holds model fallback policy parsed from engine.firewall.apiProxy.modelFallback. -// It maps to the apiProxy.modelFallback field in the generated AWF config. -type EngineModelFallbackConfig struct { - // Enabled controls whether the AWF middle-power fallback is applied when model resolution fails. - // A nil pointer means the field was not set in frontmatter; AWF will use its default (true). - Enabled *bool - - // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. - // When empty, AWF uses its default strategy. - Strategy string -} - // EngineAuthConfig represents engine.auth frontmatter settings that map to // AWF_AUTH_* environment variables consumed by the AWF API proxy sidecar. type EngineAuthConfig struct { @@ -504,24 +487,6 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } - // Extract optional 'firewall' sub-object (engine-level AWF config overrides) - if firewallVal, hasFirewall := engineObj["firewall"]; hasFirewall { - if firewallObj, ok := firewallVal.(map[string]any); ok { - if mf := parseEngineModelFallback(firewallObj); mf != nil { - config.ModelFallback = mf - enabled := "" - if mf.Enabled != nil { - if *mf.Enabled { - enabled = "true" - } else { - enabled = "false" - } - } - engineLog.Printf("Extracted engine.firewall.apiProxy.modelFallback: enabled=%s, strategy=%q", enabled, mf.Strategy) - } - } - } - // Return the ID as the engineSetting for backwards compatibility config.MaxRuns = topLevelMaxRuns config.MaxEffectiveTokens = topLevelMaxEffectiveTokens @@ -791,52 +756,3 @@ func parseEngineTokenWeights(raw any) *types.TokenWeights { } return tw } - -// parseEngineModelFallback parses the engine.firewall.apiProxy.modelFallback configuration -// from the "firewall" sub-object of the engine configuration map. Returns nil when modelFallback -// is absent or not a recognised object. The caller is responsible for logging the result. -// -// Expected frontmatter structure: -// -// engine: -// firewall: -// apiProxy: -// modelFallback: -// enabled: false -// strategy: middle_power # optional -func parseEngineModelFallback(firewallObj map[string]any) *EngineModelFallbackConfig { - apiProxyRaw, hasAPIProxy := firewallObj["apiProxy"] - if !hasAPIProxy { - return nil - } - apiProxyObj, ok := apiProxyRaw.(map[string]any) - if !ok { - return nil - } - mfRaw, hasMF := apiProxyObj["modelFallback"] - if !hasMF { - return nil - } - mfObj, ok := mfRaw.(map[string]any) - if !ok { - return nil - } - - result := &EngineModelFallbackConfig{} - if enabledRaw, hasEnabled := mfObj["enabled"]; hasEnabled { - if enabledBool, ok := enabledRaw.(bool); ok { - result.Enabled = &enabledBool - } - } - if strategyRaw, hasStrategy := mfObj["strategy"]; hasStrategy { - if strategyStr, ok := strategyRaw.(string); ok { - result.Strategy = strategyStr - } - } - - // Return nil when neither field was set — treat as absent - if result.Enabled == nil && result.Strategy == "" { - return nil - } - return result -} diff --git a/pkg/workflow/engine_test.go b/pkg/workflow/engine_test.go index f9e64a1b67a..76a1af20995 100644 --- a/pkg/workflow/engine_test.go +++ b/pkg/workflow/engine_test.go @@ -582,197 +582,3 @@ func TestTokenWeightsEnvOmittedWhenOnlyTokenClassWeightsConfigured(t *testing.T) t.Errorf("Did not expect GH_AW_INFO_TOKEN_WEIGHTS in YAML output when only token-class-weights are configured, got:\n%s", output) } } - -func TestParseEngineModelFallback(t *testing.T) { - t.Run("returns nil when firewall object has no apiProxy key", func(t *testing.T) { - firewallObj := map[string]any{"other": "value"} - got := parseEngineModelFallback(firewallObj) - if got != nil { - t.Errorf("expected nil, got %+v", got) - } - }) - - t.Run("returns nil when apiProxy is not a map", func(t *testing.T) { - firewallObj := map[string]any{"apiProxy": "not-a-map"} - got := parseEngineModelFallback(firewallObj) - if got != nil { - t.Errorf("expected nil, got %+v", got) - } - }) - - t.Run("returns nil when modelFallback is absent", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{"otherField": true}, - } - got := parseEngineModelFallback(firewallObj) - if got != nil { - t.Errorf("expected nil, got %+v", got) - } - }) - - t.Run("returns nil when modelFallback object is empty", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{}, - }, - } - got := parseEngineModelFallback(firewallObj) - if got != nil { - t.Errorf("expected nil for empty modelFallback, got %+v", got) - } - }) - - t.Run("parses enabled=false", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "enabled": false, - }, - }, - } - got := parseEngineModelFallback(firewallObj) - if got == nil { - t.Fatal("expected non-nil result") - } - if got.Enabled == nil { - t.Fatal("expected Enabled to be non-nil") - } - if *got.Enabled != false { - t.Errorf("expected Enabled=false, got %v", *got.Enabled) - } - if got.Strategy != "" { - t.Errorf("expected empty Strategy, got %q", got.Strategy) - } - }) - - t.Run("parses enabled=true", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "enabled": true, - }, - }, - } - got := parseEngineModelFallback(firewallObj) - if got == nil { - t.Fatal("expected non-nil result") - } - if got.Enabled == nil || *got.Enabled != true { - t.Errorf("expected Enabled=true, got %v", got.Enabled) - } - }) - - t.Run("parses strategy field", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "enabled": false, - "strategy": "middle_power", - }, - }, - } - got := parseEngineModelFallback(firewallObj) - if got == nil { - t.Fatal("expected non-nil result") - } - if got.Strategy != "middle_power" { - t.Errorf("expected Strategy=%q, got %q", "middle_power", got.Strategy) - } - }) - - t.Run("returns non-nil when only strategy is set (no enabled)", func(t *testing.T) { - firewallObj := map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "strategy": "middle_power", - }, - }, - } - got := parseEngineModelFallback(firewallObj) - if got == nil { - t.Fatal("expected non-nil result when strategy is set") - } - if got.Enabled != nil { - t.Errorf("expected Enabled=nil, got %v", got.Enabled) - } - if got.Strategy != "middle_power" { - t.Errorf("expected Strategy=%q, got %q", "middle_power", got.Strategy) - } - }) -} - -func TestExtractEngineConfigModelFallback(t *testing.T) { - compiler := NewCompiler() - - t.Run("parses engine.firewall.apiProxy.modelFallback enabled=false", func(t *testing.T) { - frontmatter := map[string]any{ - "engine": map[string]any{ - "id": "copilot", - "firewall": map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "enabled": false, - }, - }, - }, - }, - } - - _, config := compiler.ExtractEngineConfig(frontmatter) - if config == nil { - t.Fatal("Expected non-nil config") - } - if config.ModelFallback == nil { - t.Fatal("Expected ModelFallback to be set") - } - if config.ModelFallback.Enabled == nil { - t.Fatal("Expected ModelFallback.Enabled to be non-nil") - } - if *config.ModelFallback.Enabled != false { - t.Errorf("Expected ModelFallback.Enabled=false, got %v", *config.ModelFallback.Enabled) - } - }) - - t.Run("parses engine.firewall.apiProxy.modelFallback with strategy", func(t *testing.T) { - frontmatter := map[string]any{ - "engine": map[string]any{ - "id": "copilot", - "firewall": map[string]any{ - "apiProxy": map[string]any{ - "modelFallback": map[string]any{ - "enabled": false, - "strategy": "middle_power", - }, - }, - }, - }, - } - - _, config := compiler.ExtractEngineConfig(frontmatter) - if config == nil { - t.Fatal("Expected non-nil config") - } - if config.ModelFallback == nil { - t.Fatal("Expected ModelFallback to be set") - } - if config.ModelFallback.Strategy != "middle_power" { - t.Errorf("Expected Strategy=%q, got %q", "middle_power", config.ModelFallback.Strategy) - } - }) - - t.Run("ModelFallback is nil when engine.firewall is absent", func(t *testing.T) { - frontmatter := map[string]any{ - "engine": map[string]any{ - "id": "copilot", - }, - } - - _, config := compiler.ExtractEngineConfig(frontmatter) - if config == nil { - t.Fatal("Expected non-nil config") - } - if config.ModelFallback != nil { - t.Errorf("Expected ModelFallback to be nil, got %+v", config.ModelFallback) - } - }) -} diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 815b251cc51..5127e26aa3f 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -243,6 +243,30 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract modelFallback (AWF API proxy model fallback policy) + if mfVal, hasMF := agentObj["modelFallback"]; hasMF { + if mfObj, ok := mfVal.(map[string]any); ok { + mf := &SandboxModelFallbackConfig{} + hasFields := false + if enabledRaw, hasEnabled := mfObj["enabled"]; hasEnabled { + if enabledBool, ok := enabledRaw.(bool); ok { + mf.Enabled = &enabledBool + hasFields = true + } + } + if strategyRaw, hasStrategy := mfObj["strategy"]; hasStrategy { + if strategyStr, ok := strategyRaw.(string); ok { + mf.Strategy = strategyStr + hasFields = true + } + } + if hasFields { + agentConfig.ModelFallback = mf + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.modelFallback") + } + } + } + return agentConfig } diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index ee12b26b171..27c72dd397e 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -24,6 +24,88 @@ func TestExtractAgentSandboxConfigVersion(t *testing.T) { }) } +func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { + compiler := &Compiler{} + + t.Run("extracts sandbox.agent.modelFallback enabled=false", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "modelFallback": map[string]any{ + "enabled": false, + }, + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") + assert.False(t, *config.ModelFallback.Enabled, "Enabled should be false") + assert.Empty(t, config.ModelFallback.Strategy, "Strategy should be empty") + }) + + t.Run("extracts sandbox.agent.modelFallback enabled=true", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "modelFallback": map[string]any{ + "enabled": true, + }, + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") + assert.True(t, *config.ModelFallback.Enabled, "Enabled should be true") + }) + + t.Run("extracts sandbox.agent.modelFallback with strategy", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "modelFallback": map[string]any{ + "enabled": false, + "strategy": "middle_power", + }, + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + assert.Equal(t, "middle_power", config.ModelFallback.Strategy, "Should extract strategy") + }) + + t.Run("modelFallback is nil when absent", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when not configured") + }) + + t.Run("modelFallback is nil when object is empty", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "modelFallback": map[string]any{}, + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when object has no recognised fields") + }) + + t.Run("modelFallback is nil when value is not a map", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "modelFallback": "not-a-map", + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil for non-map value") + }) +} + // TestExtractMCPGatewayConfigPayloadFields tests extraction of payload-related fields // from MCP gateway frontmatter configuration func TestExtractMCPGatewayConfigPayloadFields(t *testing.T) { diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 525926167a5..6d34709d9f6 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -46,16 +46,29 @@ type SandboxConfig struct { // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { - ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) - Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) - Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version - Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. - Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) - Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation - Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command - Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step - Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") - Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) + Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) + Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version + Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. + Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) + Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation + Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command + Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") + Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ModelFallback *SandboxModelFallbackConfig `yaml:"modelFallback,omitempty"` // AWF API proxy model fallback policy (optional) +} + +// SandboxModelFallbackConfig holds the model fallback policy parsed from sandbox.agent.modelFallback. +// It maps to the apiProxy.modelFallback field in the generated AWF config. +type SandboxModelFallbackConfig struct { + // Enabled controls whether the AWF middle-power fallback is applied when model resolution fails. + // A nil pointer means the field was not set in frontmatter; AWF will use its default (true). + Enabled *bool `yaml:"enabled,omitempty"` + + // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. + // When empty, AWF uses its default strategy. + Strategy string `yaml:"strategy,omitempty"` } // SandboxRuntimeConfig represents the Anthropic Sandbox Runtime configuration From 3f06539d20e7c100ccaa43d0aee0d21c474689ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 05:30:55 +0000 Subject: [PATCH 05/10] fix: correct spelling in test comment Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/frontmatter_extraction_security_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 27c72dd397e..324f2451681 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -91,7 +91,7 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when object has no recognised fields") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when object has no recognized fields") }) t.Run("modelFallback is nil when value is not a map", func(t *testing.T) { From 81c63f06f02123f0ae26e23a6522ef2b52e1aa00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 05:39:31 +0000 Subject: [PATCH 06/10] fix: rename modelFallback to model-fallback in frontmatter YAML key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...se-api-proxy-model-fallback-frontmatter.md | 12 +++---- .../docs/reference/frontmatter-full.md | 2 +- pkg/workflow/awf_config_test.go | 12 +++---- .../frontmatter_extraction_security.go | 6 ++-- .../frontmatter_extraction_security_test.go | 32 +++++++++---------- pkg/workflow/sandbox.go | 4 +-- 6 files changed, 33 insertions(+), 35 deletions(-) diff --git a/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md index ff9b0daf6c1..50ecb189e1f 100644 --- a/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md +++ b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md @@ -2,15 +2,13 @@ "gh-aw": minor --- -Expose `engine.firewall.apiProxy.modelFallback` in the compiler frontmatter so BYOK Azure OpenAI users can disable the middle-power fallback strategy that rewrites deployment names and causes HTTP 404 `DeploymentNotFound` errors. +Expose `sandbox.agent.model-fallback` in the compiler frontmatter so BYOK Azure OpenAI users can disable the middle-power fallback strategy that rewrites deployment names and causes HTTP 404 `DeploymentNotFound` errors. Example usage: ```yaml -engine: - id: copilot - firewall: - apiProxy: - modelFallback: - enabled: false +sandbox: + agent: + model-fallback: + enabled: false ``` diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 9d2dff5b8bb..8555671e142 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1867,7 +1867,7 @@ sandbox: # base-catalog names. Set enabled: false for BYOK Azure OpenAI deployments to # prevent deployment-name rewriting that causes HTTP 404 DeploymentNotFound errors. # (optional) - modelFallback: + model-fallback: # Enable or disable the middle-power fallback when model resolution fails. # Defaults to true (AWF default). Set to false for custom-provider or BYOK # Azure deployments where deployment-name rewriting is undesired. diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 297c555f0f4..1175e9a49d3 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -455,7 +455,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.NotContains(t, jsonStr, "\\u0026", "JSON output should not HTML-escape '&' characters") }) - t.Run("modelFallback is emitted when enabled is explicitly set to false", func(t *testing.T) { + t.Run("model-fallback is emitted when enabled is explicitly set to false", func(t *testing.T) { falseVal := false config := AWFCommandConfig{ EngineName: "copilot", @@ -484,7 +484,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.NotContains(t, jsonStr, `"strategy"`, "apiProxy.modelFallback should omit strategy when not set") }) - t.Run("modelFallback is emitted when enabled is explicitly set to true", func(t *testing.T) { + t.Run("model-fallback is emitted when enabled is explicitly set to true", func(t *testing.T) { trueVal := true config := AWFCommandConfig{ EngineName: "copilot", @@ -512,7 +512,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, `"enabled":true`, "apiProxy.modelFallback.enabled should be true") }) - t.Run("modelFallback includes strategy when set", func(t *testing.T) { + t.Run("model-fallback includes strategy when set", func(t *testing.T) { falseVal := false config := AWFCommandConfig{ EngineName: "copilot", @@ -541,7 +541,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, `"strategy":"middle_power"`, "apiProxy.modelFallback should include strategy when set") }) - t.Run("modelFallback is omitted when not configured in sandbox", func(t *testing.T) { + t.Run("model-fallback is omitted when not configured in sandbox", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -737,7 +737,7 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { }, }, { - name: "config with modelFallback disabled", + name: "config with model-fallback disabled", config: AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -758,7 +758,7 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { }, }, { - name: "config with modelFallback disabled and strategy set", + name: "config with model-fallback disabled and strategy set", config: AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 5127e26aa3f..7d7182f494f 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -243,8 +243,8 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } - // Extract modelFallback (AWF API proxy model fallback policy) - if mfVal, hasMF := agentObj["modelFallback"]; hasMF { + // Extract model-fallback (AWF API proxy model fallback policy) + if mfVal, hasMF := agentObj["model-fallback"]; hasMF { if mfObj, ok := mfVal.(map[string]any); ok { mf := &SandboxModelFallbackConfig{} hasFields := false @@ -262,7 +262,7 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } if hasFields { agentConfig.ModelFallback = mf - frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.modelFallback") + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.model-fallback") } } } diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 324f2451681..dd3145956e3 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -27,41 +27,41 @@ func TestExtractAgentSandboxConfigVersion(t *testing.T) { func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { compiler := &Compiler{} - t.Run("extracts sandbox.agent.modelFallback enabled=false", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback enabled=false", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", - "modelFallback": map[string]any{ + "model-fallback": map[string]any{ "enabled": false, }, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + require.NotNil(t, config.ModelFallback, "Should extract model-fallback") require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") assert.False(t, *config.ModelFallback.Enabled, "Enabled should be false") assert.Empty(t, config.ModelFallback.Strategy, "Strategy should be empty") }) - t.Run("extracts sandbox.agent.modelFallback enabled=true", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback enabled=true", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", - "modelFallback": map[string]any{ + "model-fallback": map[string]any{ "enabled": true, }, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + require.NotNil(t, config.ModelFallback, "Should extract model-fallback") require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") assert.True(t, *config.ModelFallback.Enabled, "Enabled should be true") }) - t.Run("extracts sandbox.agent.modelFallback with strategy", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback with strategy", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", - "modelFallback": map[string]any{ + "model-fallback": map[string]any{ "enabled": false, "strategy": "middle_power", }, @@ -69,11 +69,11 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - require.NotNil(t, config.ModelFallback, "Should extract modelFallback") + require.NotNil(t, config.ModelFallback, "Should extract model-fallback") assert.Equal(t, "middle_power", config.ModelFallback.Strategy, "Should extract strategy") }) - t.Run("modelFallback is nil when absent", func(t *testing.T) { + t.Run("model-fallback is nil when absent", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", } @@ -83,10 +83,10 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when not configured") }) - t.Run("modelFallback is nil when object is empty", func(t *testing.T) { + t.Run("model-fallback is nil when object is empty", func(t *testing.T) { agentObj := map[string]any{ - "id": "awf", - "modelFallback": map[string]any{}, + "id": "awf", + "model-fallback": map[string]any{}, } config := compiler.extractAgentSandboxConfig(agentObj) @@ -94,10 +94,10 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when object has no recognized fields") }) - t.Run("modelFallback is nil when value is not a map", func(t *testing.T) { + t.Run("model-fallback is nil when value is not a map", func(t *testing.T) { agentObj := map[string]any{ - "id": "awf", - "modelFallback": "not-a-map", + "id": "awf", + "model-fallback": "not-a-map", } config := compiler.extractAgentSandboxConfig(agentObj) diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 6d34709d9f6..3480209763e 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -56,10 +56,10 @@ type AgentSandboxConfig struct { Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") - ModelFallback *SandboxModelFallbackConfig `yaml:"modelFallback,omitempty"` // AWF API proxy model fallback policy (optional) + ModelFallback *SandboxModelFallbackConfig `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback policy (optional) } -// SandboxModelFallbackConfig holds the model fallback policy parsed from sandbox.agent.modelFallback. +// SandboxModelFallbackConfig holds the model fallback policy parsed from sandbox.agent.model-fallback. // It maps to the apiProxy.modelFallback field in the generated AWF config. type SandboxModelFallbackConfig struct { // Enabled controls whether the AWF middle-power fallback is applied when model resolution fails. From c6abf81185b5cd688fc9a94bdbeb394e71938ae9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:26:49 +0000 Subject: [PATCH 07/10] fix: cover sandbox model-fallback schema and compile path Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/public/editor/autocomplete-data.json | 886 +++++++++++++++--- .../docs/reference/frontmatter-full.md | 40 +- ...compile_model_fallback_integration_test.go | 37 + .../workflows/test-sandbox-model-fallback.md | 18 + pkg/parser/schema_test.go | 29 + pkg/parser/schemas/main_workflow_schema.json | 16 + pkg/workflow/awf_config.go | 8 +- 7 files changed, 872 insertions(+), 162 deletions(-) create mode 100644 pkg/cli/compile_model_fallback_integration_test.go create mode 100644 pkg/cli/workflows/test-sandbox-model-fallback.md diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index dbacfd23d3b..ff2388f67d9 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -59,7 +59,10 @@ "inlined-imports": { "type": "boolean", "desc": "If true, inline all imports (including those without inputs) at compilation time in the generated lock.yml instead of...", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "on": { @@ -240,7 +243,9 @@ "skip-if-check-failing": { "type": "null|boolean|object", "desc": "Skip workflow execution if any CI checks on the target branch are failing or pending.", - "enum": [true], + "enum": [ + true + ], "leaf": true }, "skip-roles": { @@ -262,7 +267,15 @@ "roles": { "type": "string|array", "desc": "Repository access roles required to trigger agentic workflows.", - "enum": ["admin", "maintainer", "maintain", "write", "triage", "read", "all"], + "enum": [ + "admin", + "maintainer", + "maintain", + "write", + "triage", + "read", + "all" + ], "leaf": true, "array": true }, @@ -280,7 +293,10 @@ "allow-bot-authored-trigger-comment": { "type": "boolean", "desc": "Allow the bot-posted-menu / user-checks-box pattern: when a workflow posts a checkbox-menu comment as a GitHub App bo...", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "manual-approval": { @@ -291,7 +307,17 @@ "reaction": { "type": "string|integer|object", "desc": "AI reaction to add/remove on triggering item.", - "enum": ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes", "none"], + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes", + "none" + ], "leaf": true }, "status-comment": { @@ -323,9 +349,11 @@ "desc": "Additional permissions for the pre-activation job." }, "stale-check": { - "type": "boolean", - "desc": "When set to false, disables the frontmatter hash check step in the activation job.", - "enum": [true, false], + "type": "boolean|string", + "desc": "Controls the stale lock file check in the activation job.", + "enum": [ + "full" + ], "leaf": true } } @@ -333,120 +361,195 @@ "permissions": { "type": "string|object", "desc": "GitHub token permissions for the workflow.", - "enum": ["read-all", "write-all"], + "enum": [ + "read-all", + "write-all" + ], "children": { "actions": { "type": "string", "desc": "Permission for GitHub Actions workflows and runs (read: view workflows, write: manage workflows, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "attestations": { "type": "string", "desc": "Permission for artifact attestations (read: view attestations, write: create attestations, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "checks": { "type": "string", "desc": "Permission for repository checks and status checks (read: view checks, write: create/update checks, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "contents": { "type": "string", "desc": "Permission for repository contents (read: view files, write: modify files/branches, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "deployments": { "type": "string", "desc": "Permission for repository deployments (read: view deployments, write: create/update deployments, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "discussions": { "type": "string", "desc": "Permission for repository discussions (read: view discussions, write: create/update discussions, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "id-token": { "type": "string", "desc": "Permission level for OIDC token requests (write/none only - read is not supported).", - "enum": ["write", "none"], + "enum": [ + "write", + "none" + ], "leaf": true }, "issues": { "type": "string", "desc": "Permission for repository issues (read: view issues, write: create/update/close issues, none: no access)", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "models": { "type": "string", "desc": "Permission for GitHub Copilot models (read: access AI models for agentic workflows, none: no access)", - "enum": ["read", "none"], + "enum": [ + "read", + "none" + ], "leaf": true }, "metadata": { "type": "string", "desc": "Permission for repository metadata (read: view repository information, write: update repository metadata, none: no ac...", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "packages": { "type": "string", "desc": "Permission level for GitHub Packages (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "pages": { "type": "string", "desc": "Permission level for GitHub Pages (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "pull-requests": { "type": "string", "desc": "Permission level for pull requests (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "repository-projects": { "type": "string", "desc": "Permission level for repository projects (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "organization-projects": { "type": "string", "desc": "Permission level for organization projects (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "security-events": { "type": "string", "desc": "Permission level for security events (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "statuses": { "type": "string", "desc": "Permission level for commit statuses (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "vulnerability-alerts": { "type": "string", "desc": "Permission level for Dependabot vulnerability alerts (read/write/none).", - "enum": ["read", "write", "none"], + "enum": [ + "read", + "write", + "none" + ], "leaf": true }, "all": { "type": "string", "desc": "Permission shorthand that applies read access to all permission scopes.", - "enum": ["read"], + "enum": [ + "read" + ], "leaf": true } } @@ -499,13 +602,19 @@ "cancel-in-progress": { "type": "boolean", "desc": "Whether to cancel in-progress workflows in the same concurrency group when a new one starts.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "queue": { "type": "string", "desc": "Pending run queue behavior for this concurrency group.", - "enum": ["single", "max"], + "enum": [ + "single", + "max" + ], "leaf": true }, "job-discriminator": { @@ -523,7 +632,10 @@ "inline-sub-agents": { "type": "boolean", "desc": "Deprecated switch for inline sub-agent support.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "features": { @@ -541,7 +653,10 @@ "storage": { "type": "string", "desc": "Storage backend for experiment state.", - "enum": ["cache", "repo"], + "enum": [ + "cache", + "repo" + ], "leaf": true } } @@ -549,7 +664,10 @@ "disable-model-invocation": { "type": "boolean", "desc": "Controls whether the custom agent should disable model invocation.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "secrets": { @@ -625,7 +743,9 @@ "network": { "type": "string|object", "desc": "Network access control for AI engines using ecosystem identifiers and domain allowlists.", - "enum": ["defaults"], + "enum": [ + "defaults" + ], "children": { "allowed": { "type": "array", @@ -635,7 +755,10 @@ "allowed-input": { "type": "boolean", "desc": "When true and the workflow uses workflow_call, expose a network_allowed string input on the compiled lock file.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "blocked": { @@ -648,29 +771,41 @@ "sandbox": { "type": "string|object", "desc": "Sandbox configuration for AI engines.", - "enum": ["default", "awf"], + "enum": [ + "default", + "awf" + ], "children": { "type": { "type": "string", "desc": "Legacy sandbox type field (use agent instead).", - "enum": ["default", "awf"], + "enum": [ + "default", + "awf" + ], "leaf": true }, "agent": { "type": "boolean|string|object", "desc": "Agent sandbox type: 'awf' uses AWF (Agent Workflow Firewall), or false to disable agent sandbox.", - "enum": ["awf"], + "enum": [ + "awf" + ], "children": { "id": { "type": "string", "desc": "Agent identifier (replaces 'type' field in new format): 'awf' for Agent Workflow Firewall", - "enum": ["awf"], + "enum": [ + "awf" + ], "leaf": true }, "type": { "type": "string", "desc": "Legacy: Sandbox type to use (use 'id' instead)", - "enum": ["awf"], + "enum": [ + "awf" + ], "leaf": true }, "version": { @@ -702,6 +837,29 @@ "desc": "Memory limit for the AWF container (e.g., '4g', '8g').", "leaf": true }, + "model-fallback": { + "type": "object", + "desc": "Model fallback policy for unresolved model selections.", + "children": { + "enabled": { + "type": "boolean", + "desc": "Enable or disable model fallback.", + "enum": [ + true, + false + ], + "leaf": true + }, + "strategy": { + "type": "string", + "desc": "Fallback selection strategy.", + "enum": [ + "middle_power" + ], + "leaf": true + } + } + }, "config": { "type": "object", "desc": "Custom sandbox runtime configuration.", @@ -717,7 +875,10 @@ "enableWeakerNestedSandbox": { "type": "boolean", "desc": "Enable weaker nested sandbox mode (recommended: true for Docker access)", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true } } @@ -756,7 +917,10 @@ "enableWeakerNestedSandbox": { "type": "boolean", "desc": "When true, allows nested sandbox processes to run with relaxed restrictions.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true } } @@ -812,7 +976,10 @@ "domain": { "type": "string", "desc": "Gateway domain for URL generation (default: 'host.docker.internal' when agent is enabled, 'localhost' when disabled)", - "enum": ["localhost", "host.docker.internal"], + "enum": [ + "localhost", + "host.docker.internal" + ], "leaf": true }, "keepalive-interval": { @@ -868,6 +1035,17 @@ "desc": "Optional specific LLM model to use (e.g., 'claude-3-5-sonnet-20241022', 'gpt-4').", "leaf": true }, + "permission-mode": { + "type": "string", + "desc": "Claude permission mode override.", + "enum": [ + "auto", + "acceptEdits", + "plan", + "bypassPermissions" + ], + "leaf": true + }, "max-turns": { "type": "integer|string", "desc": "Maximum number of chat iterations per run.", @@ -890,13 +1068,19 @@ "cancel-in-progress": { "type": "boolean", "desc": "Whether to cancel in-progress runs of the same concurrency group.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "queue": { "type": "string", "desc": "Pending run queue behavior for this concurrency group.", - "enum": ["single", "max"], + "enum": [ + "single", + "max" + ], "leaf": true } } @@ -927,7 +1111,9 @@ "type": { "type": "string", "desc": "Authentication type.", - "enum": ["github-oidc"], + "enum": [ + "github-oidc" + ], "leaf": true }, "audience": { @@ -1021,7 +1207,10 @@ "bare": { "type": "boolean", "desc": "When true, disables automatic loading of context and custom instructions by the AI engine.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "mcp": { @@ -1066,19 +1255,26 @@ "children": { "allowed": { "type": "array", - "desc": "List of allowed GitHub API functions (e.g., 'create_issue', 'update_issue', 'add_comment')", + "desc": "List of allowed GitHub API functions.", "array": true }, "mode": { "type": "string", "desc": "GitHub access mode.", - "enum": ["gh-proxy", "local", "remote"], + "enum": [ + "gh-proxy", + "local", + "remote" + ], "leaf": true }, "type": { "type": "string", "desc": "GitHub MCP transport type: 'local' (Docker-based, default) or 'remote' (hosted at api.githubcopilot.com)", - "enum": ["local", "remote"], + "enum": [ + "local", + "remote" + ], "leaf": true }, "version": { @@ -1094,19 +1290,28 @@ "read-only": { "type": "boolean", "desc": "Enable read-only mode to restrict GitHub MCP server to read-only operations only", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "lockdown": { "type": "boolean", "desc": "Enable lockdown mode to limit content surfaced from public repositories (only items authored by users with push access).", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "integrity-proxy": { "type": "boolean", "desc": "Controls DIFC proxy injection for pre-agent gh CLI steps when guard policies (min-integrity) are configured.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "github-token": { @@ -1152,21 +1357,34 @@ "allowed-repos": { "type": "string|array", "desc": "Guard policy: repository access configuration.", - "enum": ["all", "public", "${{ github.repository }}"], + "enum": [ + "all", + "public", + "${{ github.repository }}" + ], "leaf": true, "array": true }, "repos": { "type": "string|array", "desc": "Deprecated.", - "enum": ["all", "public", "${{ github.repository }}"], + "enum": [ + "all", + "public", + "${{ github.repository }}" + ], "leaf": true, "array": true }, "min-integrity": { "type": "string", "desc": "Guard policy: minimum required integrity level for repository access.", - "enum": ["none", "unapproved", "approved", "merged"], + "enum": [ + "none", + "unapproved", + "approved", + "merged" + ], "leaf": true }, "blocked-users": { @@ -1200,13 +1418,22 @@ "disapproval-integrity": { "type": "string", "desc": "Guard policy: integrity level assigned when a disapproval reaction is present.", - "enum": ["none", "unapproved", "approved", "merged"], + "enum": [ + "none", + "unapproved", + "approved", + "merged" + ], "leaf": true }, "endorser-min-integrity": { "type": "string", "desc": "Guard policy: minimum integrity level required for an endorser (reactor) to promote content.", - "enum": ["unapproved", "approved", "merged"], + "enum": [ + "unapproved", + "approved", + "merged" + ], "leaf": true }, "github-app": { @@ -1231,7 +1458,10 @@ "ignore-if-missing": { "type": "boolean", "desc": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "owner": { @@ -1274,7 +1504,7 @@ "leaf": true }, "edit": { - "type": "null|object", + "type": "null|boolean|object", "desc": "File editing tool for reading, creating, and modifying files in the repository", "leaf": true }, @@ -1295,7 +1525,10 @@ "mode": { "type": "string", "desc": "Integration mode: 'cli' (recommended) installs @playwright/cli via npm for token-efficient CLI invocations — use play...", - "enum": ["cli", "mcp"], + "enum": [ + "cli", + "mcp" + ], "leaf": true } } @@ -1327,13 +1560,19 @@ "restore-only": { "type": "boolean", "desc": "If true, only restore the cache without saving it back.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "scope": { "type": "string", "desc": "Cache restore key scope: 'workflow' (default, only restores from same workflow) or 'repo' (restores from any workflow...", - "enum": ["workflow", "repo"], + "enum": [ + "workflow", + "repo" + ], "leaf": true }, "allowed-extensions": { @@ -1376,7 +1615,10 @@ "footer": { "type": "boolean", "desc": "Controls whether AI-generated footer is added to the managed comment.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "github-token": { @@ -1387,7 +1629,10 @@ "staged": { "type": "boolean", "desc": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true } } @@ -1405,7 +1650,10 @@ "cli-proxy": { "type": "boolean", "desc": "When true, each user-facing MCP server is mounted as a standalone CLI tool on PATH.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "serena": { @@ -1459,13 +1707,19 @@ "create-orphan": { "type": "boolean", "desc": "Create orphaned branch if it doesn't exist (default: true)", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "wiki": { "type": "boolean", "desc": "Use the GitHub Wiki git repository instead of the regular repository.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "allowed-extensions": { @@ -1507,13 +1761,19 @@ "fail-on-cache-miss": { "type": "boolean", "desc": "Fail the workflow if cache entry is not found", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "lookup-only": { "type": "boolean", "desc": "If true, only checks if cache entry exists and skips download", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "name": { @@ -1638,6 +1898,11 @@ "desc": "Enable AI agents to create autofixes for code scanning alerts using the GitHub REST API.", "leaf": true }, + "create-check-run": { + "type": "object|null", + "desc": "Enable AI agents to create GitHub Check Runs that surface analysis results in the PR checks UI.", + "leaf": true + }, "add-labels": { "type": "null|object", "desc": "Enable AI agents to add labels to GitHub issues or pull requests based on workflow analysis or classification.", @@ -1760,7 +2025,10 @@ "staged": { "type": "boolean", "desc": "If true, emit step summary messages instead of making GitHub API calls (preview mode)", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "env": { @@ -1811,7 +2079,10 @@ "footer": { "type": "boolean", "desc": "Global footer control for all safe outputs.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "activation-comments": { @@ -1822,13 +2093,19 @@ "group-reports": { "type": "boolean", "desc": "When true, creates a parent '[aw] Failed runs' issue that tracks all workflow failures as sub-issues.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "report-failure-as-issue": { "type": "boolean", "desc": "When false, disables creating failure tracking issues when workflows fail.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "failure-issue-repo": { @@ -1844,7 +2121,10 @@ "id-token": { "type": "string", "desc": "Override the id-token permission for the safe-outputs job.", - "enum": ["write", "none"], + "enum": [ + "write", + "none" + ], "leaf": true }, "concurrency-group": { @@ -1926,8 +2206,42 @@ "if-missing": { "type": "string", "desc": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets).", - "enum": ["error", "warn", "ignore"], + "enum": [ + "error", + "warn", + "ignore" + ], "leaf": true + }, + "github-app": { + "type": "object", + "desc": "Optional runtime authentication for OTLP export.", + "children": { + "app-id": { + "type": "string", + "desc": "Deprecated alias for client-id.", + "leaf": true + }, + "client-id": { + "type": "string", + "desc": "GitHub App client ID (e.g., '${{ vars.APP_ID }}').", + "leaf": true + }, + "private-key": { + "type": "string", + "desc": "GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}').", + "leaf": true + }, + "ignore-if-missing": { + "type": "boolean", + "desc": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime.", + "enum": [ + true, + false + ], + "leaf": true + } + } } } } @@ -1993,25 +2307,37 @@ "strict": { "type": "boolean", "desc": "Enable strict mode validation for enhanced security and compliance.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "private": { "type": "boolean", "desc": "Mark the workflow as private, preventing it from being added to other repositories via 'gh aw add'.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "check-for-updates": { "type": "boolean", "desc": "Control whether the compile-agentic version update check runs in the activation job.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "run-install-scripts": { "type": "boolean", "desc": "Allow npm pre/post install scripts to execute during package installation.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "mcp-scripts": { @@ -2025,7 +2351,9 @@ "checkout": { "type": "object|array|boolean", "desc": "Checkout configuration for the agent job.", - "enum": [false], + "enum": [ + false + ], "children": { "repository": { "type": "string", @@ -2055,13 +2383,20 @@ "submodules": { "type": "string|boolean", "desc": "Controls submodule checkout.", - "enum": ["recursive", "true", "false"], + "enum": [ + "recursive", + "true", + "false" + ], "leaf": true }, "lfs": { "type": "boolean", "desc": "Whether to download Git LFS objects.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "token": { @@ -2096,7 +2431,10 @@ "ignore-if-missing": { "type": "boolean", "desc": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "owner": { @@ -2116,181 +2454,300 @@ "administration": { "type": "string", "desc": "Permission level for repository administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces": { "type": "string", "desc": "Permission level for Codespaces (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces-lifecycle-admin": { "type": "string", "desc": "Permission level for Codespaces lifecycle administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces-metadata": { "type": "string", "desc": "Permission level for Codespaces metadata (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "email-addresses": { "type": "string", "desc": "Permission level for user email addresses (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "environments": { "type": "string", "desc": "Permission level for repository environments (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "git-signing": { "type": "string", "desc": "Permission level for git signing (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "members": { "type": "string", "desc": "Permission level for organization members (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-administration": { "type": "string", "desc": "Permission level for organization administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-announcement-banners": { "type": "string", "desc": "Permission level for organization announcement banners (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-codespaces": { "type": "string", "desc": "Permission level for organization Codespaces (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-copilot": { "type": "string", "desc": "Permission level for organization Copilot (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-org-roles": { "type": "string", "desc": "Permission level for organization custom org roles (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-properties": { "type": "string", "desc": "Permission level for organization custom properties (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-repository-roles": { "type": "string", "desc": "Permission level for organization custom repository roles (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-events": { "type": "string", "desc": "Permission level for organization events (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-hooks": { "type": "string", "desc": "Permission level for organization webhooks (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-members": { "type": "string", "desc": "Permission level for organization members management (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-packages": { "type": "string", "desc": "Permission level for organization packages (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-personal-access-token-requests": { "type": "string", "desc": "Permission level for organization personal access token requests (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-personal-access-tokens": { "type": "string", "desc": "Permission level for organization personal access tokens (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-plan": { "type": "string", "desc": "Permission level for organization plan (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-self-hosted-runners": { "type": "string", "desc": "Permission level for organization self-hosted runners (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-user-blocking": { "type": "string", "desc": "Permission level for organization user blocking (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "repository-custom-properties": { "type": "string", "desc": "Permission level for repository custom properties (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "repository-hooks": { "type": "string", "desc": "Permission level for repository webhooks (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "single-file": { "type": "string", "desc": "Permission level for single file access (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "team-discussions": { "type": "string", "desc": "Permission level for team discussions (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "vulnerability-alerts": { "type": "string", "desc": "Permission level for Dependabot vulnerability alerts (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none"], + "enum": [ + "read", + "none" + ], "leaf": true }, "workflows": { "type": "string", "desc": "Permission level for GitHub Actions workflow files (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true } } @@ -2300,7 +2757,10 @@ "current": { "type": "boolean", "desc": "Marks this checkout as the logical current repository for the workflow.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "fetch": { @@ -2312,13 +2772,19 @@ "wiki": { "type": "boolean", "desc": "When true, clones the repository's wiki git instead of the regular repository.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "force-clean-git-credentials": { "type": "boolean", "desc": "When true, persist credentials during checkout, then immediately run a post-checkout cleanup step that removes creden...", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true } }, @@ -2346,7 +2812,10 @@ "ignore-if-missing": { "type": "boolean", "desc": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime.", - "enum": [true, false], + "enum": [ + true, + false + ], "leaf": true }, "owner": { @@ -2366,181 +2835,300 @@ "administration": { "type": "string", "desc": "Permission level for repository administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces": { "type": "string", "desc": "Permission level for Codespaces (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces-lifecycle-admin": { "type": "string", "desc": "Permission level for Codespaces lifecycle administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "codespaces-metadata": { "type": "string", "desc": "Permission level for Codespaces metadata (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "email-addresses": { "type": "string", "desc": "Permission level for user email addresses (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "environments": { "type": "string", "desc": "Permission level for repository environments (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "git-signing": { "type": "string", "desc": "Permission level for git signing (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "members": { "type": "string", "desc": "Permission level for organization members (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-administration": { "type": "string", "desc": "Permission level for organization administration (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-announcement-banners": { "type": "string", "desc": "Permission level for organization announcement banners (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-codespaces": { "type": "string", "desc": "Permission level for organization Codespaces (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-copilot": { "type": "string", "desc": "Permission level for organization Copilot (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-org-roles": { "type": "string", "desc": "Permission level for organization custom org roles (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-properties": { "type": "string", "desc": "Permission level for organization custom properties (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-custom-repository-roles": { "type": "string", "desc": "Permission level for organization custom repository roles (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-events": { "type": "string", "desc": "Permission level for organization events (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-hooks": { "type": "string", "desc": "Permission level for organization webhooks (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-members": { "type": "string", "desc": "Permission level for organization members management (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-packages": { "type": "string", "desc": "Permission level for organization packages (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-personal-access-token-requests": { "type": "string", "desc": "Permission level for organization personal access token requests (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-personal-access-tokens": { "type": "string", "desc": "Permission level for organization personal access tokens (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-plan": { "type": "string", "desc": "Permission level for organization plan (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-self-hosted-runners": { "type": "string", "desc": "Permission level for organization self-hosted runners (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "organization-user-blocking": { "type": "string", "desc": "Permission level for organization user blocking (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "repository-custom-properties": { "type": "string", "desc": "Permission level for repository custom properties (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "repository-hooks": { "type": "string", "desc": "Permission level for repository webhooks (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "single-file": { "type": "string", "desc": "Permission level for single file access (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "team-discussions": { "type": "string", "desc": "Permission level for team discussions (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true }, "vulnerability-alerts": { "type": "string", "desc": "Permission level for Dependabot vulnerability alerts (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none"], + "enum": [ + "read", + "none" + ], "leaf": true }, "workflows": { "type": "string", "desc": "Permission level for GitHub Actions workflow files (read/none; \"write\" is rejected by the compiler).", - "enum": ["read", "none", "write"], + "enum": [ + "read", + "none", + "write" + ], "leaf": true } } @@ -2593,4 +3181,4 @@ "runtimes", "jobs" ] -} +} \ No newline at end of file diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 8555671e142..eef7d7bb34f 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1862,20 +1862,15 @@ sandbox: # (optional) memory: "example-value" - # Model fallback policy for unresolved model selections. AWF default is enabled - # with the middle_power strategy, which silently rewrites deployment names to - # base-catalog names. Set enabled: false for BYOK Azure OpenAI deployments to - # prevent deployment-name rewriting that causes HTTP 404 DeploymentNotFound errors. + # Model fallback policy for unresolved model selections. Set enabled: false for + # BYOK Azure OpenAI deployments to prevent deployment-name rewriting. # (optional) model-fallback: - # Enable or disable the middle-power fallback when model resolution fails. - # Defaults to true (AWF default). Set to false for custom-provider or BYOK - # Azure deployments where deployment-name rewriting is undesired. + # Enable or disable model fallback. Omit to use the AWF default. # (optional) enabled: true # Fallback selection strategy. Currently only "middle_power" is supported. - # Omit to use the AWF default. # (optional) strategy: "middle_power" @@ -2537,11 +2532,11 @@ tools: # Format 4: GitHub tools object configuration with restricted function access github: - # List of allowed GitHub API functions (e.g., 'create_issue', 'update_issue', - # 'add_comment') + # List of allowed GitHub API functions. Each entry can be a string tool name + # (e.g., 'issue_read') or an object with per-tool limits (e.g., {name: + # 'issue_read', max-calls: 1}) # (optional) allowed: [] - # Array of strings # GitHub access mode. Prefer 'gh-proxy' for better performance (uses # pre-authenticated gh CLI prompt guidance). Legacy MCP transport values 'local' @@ -7606,6 +7601,29 @@ observability: # (optional) if-missing: "error" + # Optional runtime authentication for OTLP export. Supports GitHub App credentials + # (client-id/app-id + private-key) for token minting, or implicit GitHub OIDC mode + # when the github-app object is present without credentials. + # (optional) + github-app: + # Deprecated alias for client-id. GitHub App ID/client ID (e.g., '${{ vars.APP_ID + # }}'). + # (optional) + app-id: "example-value" + + # GitHub App client ID (e.g., '${{ vars.APP_ID }}'). + # (optional) + client-id: "example-value" + + # GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}'). + # (optional) + private-key: "example-value" + + # If true, skip token minting when client-id/private-key resolve to empty strings + # at runtime. Defaults to false. + # (optional) + ignore-if-missing: true + # Rate limiting configuration to restrict how frequently users can trigger the # workflow. Helps prevent abuse and resource exhaustion from programmatically # triggered events. diff --git a/pkg/cli/compile_model_fallback_integration_test.go b/pkg/cli/compile_model_fallback_integration_test.go new file mode 100644 index 00000000000..198de4d8617 --- /dev/null +++ b/pkg/cli/compile_model_fallback_integration_test.go @@ -0,0 +1,37 @@ +//go:build integration + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompileSandboxModelFallbackWorkflow(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-sandbox-model-fallback.md") + dstPath := filepath.Join(setup.workflowsDir, "test-sandbox-model-fallback.md") + + srcContent, err := os.ReadFile(srcPath) + require.NoError(t, err, "should read source workflow file") + require.NoError(t, os.WriteFile(dstPath, srcContent, 0644), "should write workflow to test dir") + + cmd := exec.Command(setup.binaryPath, "compile", dstPath) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "compile command should succeed\nOutput: %s", string(output)) + + lockPath := filepath.Join(setup.workflowsDir, "test-sandbox-model-fallback.lock.yml") + lockContent, err := os.ReadFile(lockPath) + require.NoError(t, err, "should read lock file") + lockStr := string(lockContent) + + assert.Contains(t, lockStr, `"modelFallback":{"enabled":false,"strategy":"middle_power"}`, + "compiled lock file should embed sandbox.agent.model-fallback in the AWF config JSON") +} diff --git a/pkg/cli/workflows/test-sandbox-model-fallback.md b/pkg/cli/workflows/test-sandbox-model-fallback.md new file mode 100644 index 00000000000..38f09bac1ed --- /dev/null +++ b/pkg/cli/workflows/test-sandbox-model-fallback.md @@ -0,0 +1,18 @@ +--- +name: Test Sandbox Model Fallback +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +sandbox: + agent: + id: awf + model-fallback: + enabled: false + strategy: middle_power +--- + +# Test Sandbox Model Fallback + +Verify that sandbox.agent.model-fallback compiles into the AWF config JSON. diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 38bc060c959..3b86d4af8c3 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1342,6 +1342,35 @@ func TestMainWorkflowSchema_ProtectedFilesObjectFormStructure(t *testing.T) { } } +func TestMainWorkflowSchema_SandboxAgentModelFallback(t *testing.T) { + t.Parallel() + + validFrontmatter := map[string]any{ + "name": "sandbox-agent-model-fallback", + "on": map[string]any{ + "workflow_dispatch": map[string]any{}, + }, + "permissions": map[string]any{ + "contents": "read", + }, + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "id": "awf", + "model-fallback": map[string]any{ + "enabled": false, + "strategy": "middle_power", + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/sandbox-agent-model-fallback-test.md") + if err != nil { + t.Fatalf("expected sandbox.agent.model-fallback to pass schema validation, got: %v", err) + } +} + // TestValidateWithSchema_YAMLIntegerTypes verifies that validateWithSchema accepts // YAML-native integer types (uint64/int64) when the schema expects number/integer. func TestValidateWithSchema_YAMLIntegerTypes(t *testing.T) { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 11ed9b591ba..70a347ee713 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3291,6 +3291,22 @@ "pattern": "^[0-9]+(b|k|m|g|kb|mb|gb|B|K|M|G|KB|MB|GB)$", "examples": ["4g", "8g", "512m"] }, + "model-fallback": { + "type": "object", + "description": "Model fallback policy for unresolved model selections. Set enabled: false for BYOK Azure OpenAI deployments to prevent deployment-name rewriting.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable model fallback. Omit to use the AWF default." + }, + "strategy": { + "type": "string", + "enum": ["middle_power"], + "description": "Fallback selection strategy. Currently only \"middle_power\" is supported." + } + }, + "additionalProperties": false + }, "config": { "type": "object", "description": "Custom sandbox runtime configuration. Note: Network configuration is controlled by the top-level 'network' field, not here.", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 4d616221944..c08384b2315 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -312,7 +312,11 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { if mf := extractModelFallback(config.WorkflowData); mf != nil { apiProxy.ModelFallback = mf - awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%v, strategy=%q", mf.Enabled, mf.Strategy) + enabledValue := "" + if mf.Enabled != nil { + enabledValue = fmt.Sprintf("%t", *mf.Enabled) + } + awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s, strategy=%q", enabledValue, mf.Strategy) } targets := map[string]*AWFAPITargetConfig{} @@ -408,7 +412,7 @@ func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { } // extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured -// sandbox.agent.modelFallback, or nil if the field is absent (letting AWF use its default). +// 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 From 140a5d4fb8ee022af908bd5256b62a2e1ac04358 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:28:49 +0000 Subject: [PATCH 08/10] chore: finalize model fallback review follow-up Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/awf_config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c08384b2315..4ac8a47615b 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -312,11 +312,11 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { if mf := extractModelFallback(config.WorkflowData); mf != nil { apiProxy.ModelFallback = mf - enabledValue := "" + enabledDisplay := "" if mf.Enabled != nil { - enabledValue = fmt.Sprintf("%t", *mf.Enabled) + enabledDisplay = fmt.Sprintf("%t", *mf.Enabled) } - awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s, strategy=%q", enabledValue, mf.Strategy) + awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s, strategy=%q", enabledDisplay, mf.Strategy) } targets := map[string]*AWFAPITargetConfig{} From 497e8fc2804ee56eea80e42d25b702ea61ed0b02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:45:00 +0000 Subject: [PATCH 09/10] fix: simplify sandbox model-fallback frontmatter Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...se-api-proxy-model-fallback-frontmatter.md | 5 +- docs/public/editor/autocomplete-data.json | 24 ++------- .../docs/reference/frontmatter-full.md | 18 +++---- ...compile_model_fallback_integration_test.go | 2 +- .../workflows/test-sandbox-model-fallback.md | 4 +- pkg/parser/schema_test.go | 54 +++++++++++-------- pkg/parser/schemas/main_workflow_schema.json | 17 ++---- pkg/workflow/awf_config.go | 20 +++---- pkg/workflow/awf_config_test.go | 34 +++++------- .../frontmatter_extraction_security.go | 31 +++++------ .../frontmatter_extraction_security_test.go | 47 +++++++--------- pkg/workflow/sandbox.go | 34 ++++-------- pkg/workflow/schemas/awf-config.schema.json | 13 ++++- pkg/workflow/templatables.go | 24 +++++++++ specs/awf-config-sources-spec.md | 2 +- 15 files changed, 151 insertions(+), 178 deletions(-) diff --git a/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md index 50ecb189e1f..9c206fe259a 100644 --- a/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md +++ b/.changeset/minor-expose-api-proxy-model-fallback-frontmatter.md @@ -2,13 +2,12 @@ "gh-aw": minor --- -Expose `sandbox.agent.model-fallback` in the compiler frontmatter so BYOK Azure OpenAI users can disable the middle-power fallback strategy that rewrites deployment names and causes HTTP 404 `DeploymentNotFound` errors. +Expose `sandbox.agent.model-fallback` in the compiler frontmatter so BYOK Azure OpenAI users can disable the middle-power fallback behavior that rewrites deployment names and causes HTTP 404 `DeploymentNotFound` errors. Example usage: ```yaml sandbox: agent: - model-fallback: - enabled: false + model-fallback: false ``` diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index ff2388f67d9..e9d2d0d49a2 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -838,27 +838,9 @@ "leaf": true }, "model-fallback": { - "type": "object", - "desc": "Model fallback policy for unresolved model selections.", - "children": { - "enabled": { - "type": "boolean", - "desc": "Enable or disable model fallback.", - "enum": [ - true, - false - ], - "leaf": true - }, - "strategy": { - "type": "string", - "desc": "Fallback selection strategy.", - "enum": [ - "middle_power" - ], - "leaf": true - } - } + "type": "boolean|string", + "desc": "Enable or disable model fallback for unresolved model selections.", + "leaf": true }, "config": { "type": "object", diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index eef7d7bb34f..89887fff245 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1862,17 +1862,17 @@ sandbox: # (optional) memory: "example-value" - # Model fallback policy for unresolved model selections. Set enabled: false for - # BYOK Azure OpenAI deployments to prevent deployment-name rewriting. + # Enable or disable model fallback for unresolved model selections. Set to false + # for BYOK Azure OpenAI deployments to prevent deployment-name rewriting. Supports + # literal boolean or GitHub Actions expression. # (optional) - model-fallback: - # Enable or disable model fallback. Omit to use the AWF default. - # (optional) - enabled: true + # Accepted formats: - # Fallback selection strategy. Currently only "middle_power" is supported. - # (optional) - strategy: "middle_power" + # Format 1: boolean + model-fallback: true + + # Format 2: GitHub Actions expression that resolves to a boolean at runtime + model-fallback: "example-value" # Custom sandbox runtime configuration. Note: Network configuration is controlled # by the top-level 'network' field, not here. diff --git a/pkg/cli/compile_model_fallback_integration_test.go b/pkg/cli/compile_model_fallback_integration_test.go index 198de4d8617..1909ef04f42 100644 --- a/pkg/cli/compile_model_fallback_integration_test.go +++ b/pkg/cli/compile_model_fallback_integration_test.go @@ -32,6 +32,6 @@ func TestCompileSandboxModelFallbackWorkflow(t *testing.T) { require.NoError(t, err, "should read lock file") lockStr := string(lockContent) - assert.Contains(t, lockStr, `"modelFallback":{"enabled":false,"strategy":"middle_power"}`, + assert.Contains(t, lockStr, `"modelFallback":{"enabled":false}`, "compiled lock file should embed sandbox.agent.model-fallback in the AWF config JSON") } diff --git a/pkg/cli/workflows/test-sandbox-model-fallback.md b/pkg/cli/workflows/test-sandbox-model-fallback.md index 38f09bac1ed..9ba72ab3107 100644 --- a/pkg/cli/workflows/test-sandbox-model-fallback.md +++ b/pkg/cli/workflows/test-sandbox-model-fallback.md @@ -8,9 +8,7 @@ engine: copilot sandbox: agent: id: awf - model-fallback: - enabled: false - strategy: middle_power + model-fallback: false --- # Test Sandbox Model Fallback diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 3b86d4af8c3..0abde6a8185 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1345,29 +1345,41 @@ func TestMainWorkflowSchema_ProtectedFilesObjectFormStructure(t *testing.T) { func TestMainWorkflowSchema_SandboxAgentModelFallback(t *testing.T) { t.Parallel() - validFrontmatter := map[string]any{ - "name": "sandbox-agent-model-fallback", - "on": map[string]any{ - "workflow_dispatch": map[string]any{}, - }, - "permissions": map[string]any{ - "contents": "read", - }, - "engine": "copilot", - "sandbox": map[string]any{ - "agent": map[string]any{ - "id": "awf", - "model-fallback": map[string]any{ - "enabled": false, - "strategy": "middle_power", - }, - }, - }, + cases := []struct { + name string + modelFallback any + }{ + {name: "boolean", modelFallback: false}, + {name: "expression", modelFallback: "${{ inputs.model-fallback }}"}, } - err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/sandbox-agent-model-fallback-test.md") - if err != nil { - t.Fatalf("expected sandbox.agent.model-fallback to pass schema validation, got: %v", err) + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + validFrontmatter := map[string]any{ + "name": "sandbox-agent-model-fallback", + "on": map[string]any{ + "workflow_dispatch": map[string]any{}, + }, + "permissions": map[string]any{ + "contents": "read", + }, + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "id": "awf", + "model-fallback": tc.modelFallback, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/sandbox-agent-model-fallback-test.md") + if err != nil { + t.Fatalf("expected sandbox.agent.model-fallback to pass schema validation, got: %v", err) + } + }) } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 70a347ee713..8342adf4b8c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3292,20 +3292,9 @@ "examples": ["4g", "8g", "512m"] }, "model-fallback": { - "type": "object", - "description": "Model fallback policy for unresolved model selections. Set enabled: false for BYOK Azure OpenAI deployments to prevent deployment-name rewriting.", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable model fallback. Omit to use the AWF default." - }, - "strategy": { - "type": "string", - "enum": ["middle_power"], - "description": "Fallback selection strategy. Currently only \"middle_power\" is supported." - } - }, - "additionalProperties": false + "$ref": "#/$defs/templatable_boolean", + "description": "Enable or disable model fallback for unresolved model selections. Set to false for BYOK Azure OpenAI deployments to prevent deployment-name rewriting. Supports literal boolean or GitHub Actions expression.", + "examples": [false, "${{ inputs.model-fallback }}"] }, "config": { "type": "object", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 4ac8a47615b..525acaf5baa 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -186,17 +186,12 @@ type AWFAPIProxyConfig struct { } // AWFModelFallbackConfig is the "apiProxy.modelFallback" section of the AWF config file. -// It controls the model fallback policy for unresolved model selections. +// It controls whether model fallback is enabled for unresolved model selections. type AWFModelFallbackConfig struct { // Enabled controls whether middle-power fallback is applied when model resolution fails. - // AWF default is true. Set to false to disable for BYOK Azure / custom-provider deployments - // where deployment name rewriting causes HTTP 404 DeploymentNotFound errors. - // A nil value omits the field from the generated config, letting AWF use its default. - Enabled *bool `json:"enabled,omitempty"` - - // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. - // When omitted, AWF uses its default strategy. - Strategy string `json:"strategy,omitempty"` + // It accepts literal booleans and GitHub Actions expressions. A nil value omits the field, + // letting AWF use its default. + Enabled *TemplatableBool `json:"enabled,omitempty"` } // AWFAPITargetConfig is a single API proxy target entry. @@ -314,9 +309,9 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { apiProxy.ModelFallback = mf enabledDisplay := "" if mf.Enabled != nil { - enabledDisplay = fmt.Sprintf("%t", *mf.Enabled) + enabledDisplay = mf.Enabled.String() } - awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s, strategy=%q", enabledDisplay, mf.Strategy) + awfConfigLog.Printf("API proxy: modelFallback configured: enabled=%s", enabledDisplay) } targets := map[string]*AWFAPITargetConfig{} @@ -428,7 +423,6 @@ func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { return nil } return &AWFModelFallbackConfig{ - Enabled: mf.Enabled, - Strategy: mf.Strategy, + Enabled: mf, } } diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 1175e9a49d3..620771ddd5c 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -456,7 +456,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { }) t.Run("model-fallback is emitted when enabled is explicitly set to false", func(t *testing.T) { - falseVal := false + disabled := TemplatableBool("false") config := AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -466,9 +466,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { }, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ModelFallback: &SandboxModelFallbackConfig{ - Enabled: &falseVal, - }, + ModelFallback: &disabled, }, }, NetworkPermissions: &NetworkPermissions{ @@ -481,11 +479,10 @@ func TestBuildAWFConfigJSON(t *testing.T) { require.NoError(t, err) assert.Contains(t, jsonStr, `"modelFallback"`, "apiProxy should emit modelFallback when configured") assert.Contains(t, jsonStr, `"enabled":false`, "apiProxy.modelFallback.enabled should be false") - assert.NotContains(t, jsonStr, `"strategy"`, "apiProxy.modelFallback should omit strategy when not set") }) t.Run("model-fallback is emitted when enabled is explicitly set to true", func(t *testing.T) { - trueVal := true + enabled := TemplatableBool("true") config := AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -495,9 +492,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { }, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ModelFallback: &SandboxModelFallbackConfig{ - Enabled: &trueVal, - }, + ModelFallback: &enabled, }, }, NetworkPermissions: &NetworkPermissions{ @@ -512,8 +507,8 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, `"enabled":true`, "apiProxy.modelFallback.enabled should be true") }) - t.Run("model-fallback includes strategy when set", func(t *testing.T) { - falseVal := false + t.Run("model-fallback supports GitHub Actions expressions", func(t *testing.T) { + expr := TemplatableBool("${{ inputs.model-fallback }}") config := AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", @@ -523,10 +518,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { }, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ModelFallback: &SandboxModelFallbackConfig{ - Enabled: &falseVal, - Strategy: "middle_power", - }, + ModelFallback: &expr, }, }, NetworkPermissions: &NetworkPermissions{ @@ -538,7 +530,7 @@ func TestBuildAWFConfigJSON(t *testing.T) { jsonStr, err := BuildAWFConfigJSON(config) require.NoError(t, err) assert.Contains(t, jsonStr, `"modelFallback"`, "apiProxy should emit modelFallback when configured") - assert.Contains(t, jsonStr, `"strategy":"middle_power"`, "apiProxy.modelFallback should include strategy when set") + assert.Contains(t, jsonStr, `"enabled":"${{ inputs.model-fallback }}"`, "apiProxy.modelFallback.enabled should preserve expressions") }) t.Run("model-fallback is omitted when not configured in sandbox", func(t *testing.T) { @@ -742,12 +734,12 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { EngineName: "copilot", AllowedDomains: "github.com", WorkflowData: func() *WorkflowData { - f := false + disabled := TemplatableBool("false") return &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ModelFallback: &SandboxModelFallbackConfig{Enabled: &f}, + ModelFallback: &disabled, }, }, NetworkPermissions: &NetworkPermissions{ @@ -758,17 +750,17 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { }, }, { - name: "config with model-fallback disabled and strategy set", + name: "config with model-fallback expression", config: AWFCommandConfig{ EngineName: "copilot", AllowedDomains: "github.com", WorkflowData: func() *WorkflowData { - f := false + expr := TemplatableBool("${{ inputs.model-fallback }}") return &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, SandboxConfig: &SandboxConfig{ Agent: &AgentSandboxConfig{ - ModelFallback: &SandboxModelFallbackConfig{Enabled: &f, Strategy: "middle_power"}, + ModelFallback: &expr, }, }, NetworkPermissions: &NetworkPermissions{ diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 7d7182f494f..8c861b2ab28 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -243,25 +243,20 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } - // Extract model-fallback (AWF API proxy model fallback policy) + // Extract model-fallback (AWF API proxy model fallback enable/disable flag) if mfVal, hasMF := agentObj["model-fallback"]; hasMF { - if mfObj, ok := mfVal.(map[string]any); ok { - mf := &SandboxModelFallbackConfig{} - hasFields := false - if enabledRaw, hasEnabled := mfObj["enabled"]; hasEnabled { - if enabledBool, ok := enabledRaw.(bool); ok { - mf.Enabled = &enabledBool - hasFields = true - } - } - if strategyRaw, hasStrategy := mfObj["strategy"]; hasStrategy { - if strategyStr, ok := strategyRaw.(string); ok { - mf.Strategy = strategyStr - hasFields = true - } - } - if hasFields { - agentConfig.ModelFallback = mf + switch v := mfVal.(type) { + case bool: + value := TemplatableBool("false") + if v { + value = TemplatableBool("true") + } + agentConfig.ModelFallback = &value + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.model-fallback") + case string: + if isExpression(v) { + value := TemplatableBool(v) + agentConfig.ModelFallback = &value frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.model-fallback") } } diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index dd3145956e3..0835d123491 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -27,50 +27,41 @@ func TestExtractAgentSandboxConfigVersion(t *testing.T) { func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { compiler := &Compiler{} - t.Run("extracts sandbox.agent.model-fallback enabled=false", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback false", func(t *testing.T) { agentObj := map[string]any{ - "id": "awf", - "model-fallback": map[string]any{ - "enabled": false, - }, + "id": "awf", + "model-fallback": false, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") require.NotNil(t, config.ModelFallback, "Should extract model-fallback") - require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") - assert.False(t, *config.ModelFallback.Enabled, "Enabled should be false") - assert.Empty(t, config.ModelFallback.Strategy, "Strategy should be empty") + assert.Equal(t, "false", config.ModelFallback.String(), "Should normalize false to string form") }) - t.Run("extracts sandbox.agent.model-fallback enabled=true", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback true", func(t *testing.T) { agentObj := map[string]any{ - "id": "awf", - "model-fallback": map[string]any{ - "enabled": true, - }, + "id": "awf", + "model-fallback": true, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") require.NotNil(t, config.ModelFallback, "Should extract model-fallback") - require.NotNil(t, config.ModelFallback.Enabled, "Enabled should be non-nil") - assert.True(t, *config.ModelFallback.Enabled, "Enabled should be true") + assert.Equal(t, "true", config.ModelFallback.String(), "Should normalize true to string form") }) - t.Run("extracts sandbox.agent.model-fallback with strategy", func(t *testing.T) { + t.Run("extracts sandbox.agent.model-fallback expression", func(t *testing.T) { + expr := "${{ inputs.model-fallback }}" agentObj := map[string]any{ - "id": "awf", - "model-fallback": map[string]any{ - "enabled": false, - "strategy": "middle_power", - }, + "id": "awf", + "model-fallback": expr, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") require.NotNil(t, config.ModelFallback, "Should extract model-fallback") - assert.Equal(t, "middle_power", config.ModelFallback.Strategy, "Should extract strategy") + assert.Equal(t, expr, config.ModelFallback.String(), "Should preserve expression") }) t.Run("model-fallback is nil when absent", func(t *testing.T) { @@ -83,26 +74,26 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when not configured") }) - t.Run("model-fallback is nil when object is empty", func(t *testing.T) { + t.Run("model-fallback is nil when value is not a boolean or expression", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", - "model-fallback": map[string]any{}, + "model-fallback": "not-an-expression", } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - assert.Nil(t, config.ModelFallback, "ModelFallback should be nil when object has no recognized fields") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil for invalid strings") }) - t.Run("model-fallback is nil when value is not a map", func(t *testing.T) { + t.Run("model-fallback is nil when value is an object", func(t *testing.T) { agentObj := map[string]any{ "id": "awf", - "model-fallback": "not-a-map", + "model-fallback": map[string]any{"enabled": false}, } config := compiler.extractAgentSandboxConfig(agentObj) require.NotNil(t, config, "Should extract agent sandbox config") - assert.Nil(t, config.ModelFallback, "ModelFallback should be nil for non-map value") + assert.Nil(t, config.ModelFallback, "ModelFallback should be nil for object value") }) } diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 3480209763e..1239c377fac 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -46,29 +46,17 @@ type SandboxConfig struct { // AgentSandboxConfig represents the agent sandbox configuration type AgentSandboxConfig struct { - ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) - Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) - Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version - Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. - Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) - Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation - Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command - Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step - Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") - Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") - ModelFallback *SandboxModelFallbackConfig `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback policy (optional) -} - -// SandboxModelFallbackConfig holds the model fallback policy parsed from sandbox.agent.model-fallback. -// It maps to the apiProxy.modelFallback field in the generated AWF config. -type SandboxModelFallbackConfig struct { - // Enabled controls whether the AWF middle-power fallback is applied when model resolution fails. - // A nil pointer means the field was not set in frontmatter; AWF will use its default (true). - Enabled *bool `yaml:"enabled,omitempty"` - - // Strategy is the fallback selection strategy. Currently only "middle_power" is supported. - // When empty, AWF uses its default strategy. - Strategy string `yaml:"strategy,omitempty"` + ID string `yaml:"id,omitempty"` // Agent ID: "awf" or "srt" (replaces Type in new object format) + Type SandboxType `yaml:"type,omitempty"` // Sandbox type: "awf" or "srt" (legacy, use ID instead) + Version string `yaml:"version,omitempty"` // AWF version override used to install and run the matching firewall version + Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. + Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional) + Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation + Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command + Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") + Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g") + ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional) } // SandboxRuntimeConfig represents the Anthropic Sandbox Runtime configuration diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index e7c2a15410e..c9434985b34 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -88,8 +88,17 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", - "description": "Enable or disable middle-power fallback when model resolution fails." + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression that resolves to a boolean at runtime" + } + ], + "description": "Enable or disable middle-power fallback when model resolution fails. Supports literal booleans and GitHub Actions expressions in compiled workflows." }, "strategy": { "type": "string", diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index ccddf1fc8ef..75485f971bb 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -132,6 +132,30 @@ func (t *TemplatableInt32) Ptr() *TemplatableInt32 { return &v } +// TemplatableBool represents a boolean frontmatter field that also accepts +// GitHub Actions expression strings (e.g. "${{ inputs.enabled }}"). The +// underlying value is always stored as a string: boolean literals as "true" or +// "false", expressions verbatim. +type TemplatableBool string + +// MarshalJSON emits a JSON boolean for literal values and a JSON string for +// GitHub Actions expressions. +func (t *TemplatableBool) MarshalJSON() ([]byte, error) { + switch string(*t) { + case "true": + return json.Marshal(true) + case "false": + return json.Marshal(false) + default: + return json.Marshal(string(*t)) + } +} + +// String returns the underlying string representation of the value. +func (t *TemplatableBool) String() string { + return string(*t) +} + // buildTemplatableBoolEnvVar returns a YAML environment variable entry for a // templatable boolean field. If value is a GitHub Actions expression it is // embedded unquoted so that GitHub Actions can evaluate it at runtime; diff --git a/specs/awf-config-sources-spec.md b/specs/awf-config-sources-spec.md index b5e1fb4c50e..aa08f63b58d 100644 --- a/specs/awf-config-sources-spec.md +++ b/specs/awf-config-sources-spec.md @@ -58,7 +58,7 @@ The following fields previously existed in schema but were missed in spec CLI ma | `apiProxy.anthropicCacheTailTtl` | `--anthropic-cache-tail-ttl` | | `apiProxy.models` | config-only (model alias rewriting) | | `apiProxy.modelMultipliers` | config-only (effective-token accounting) | -| `apiProxy.modelFallback` | config-only (model fallback policy; set `enabled: false` to prevent deployment-name rewriting for BYOK Azure) | +| `apiProxy.modelFallback` | config-only (model fallback policy; set `sandbox.agent.model-fallback: false` to prevent deployment-name rewriting for BYOK Azure) | | `apiProxy.maxRuns` | config-only (LLM invocation hard cap) | | `apiProxy.auth.*` | config-only (maps to `AWF_AUTH_*` env vars) | | `container.dockerHostPathPrefix` | `--docker-host-path-prefix` | From 7898dd8216167511ec4e8c9095a8c45927dab883 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 15:25:08 +0000 Subject: [PATCH 10/10] fix: remove redundant loop variable copy in parser schema test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 0abde6a8185..d29d1acec87 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1354,7 +1354,6 @@ func TestMainWorkflowSchema_SandboxAgentModelFallback(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel()