diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 6100c62629e..674d24e70fc 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1868,6 +1868,12 @@ sandbox: # (optional) version: "example-value" + # AWF platform.type override. Declares the GitHub deployment type so AWF can + # apply deterministic Copilot auth behavior without relying on host heuristics. + # Omit to let AWF use its default host heuristic behavior. + # (optional) + platform: "ghes" + # Container mounts to add when using AWF. Each mount is specified using Docker # mount syntax: 'source:destination:mode' where mode can be 'ro' (read-only) or # 'rw' (read-write). Example: '/host/path:/container/path:ro' diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 6ffd4e20b88..91731cc84f6 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -705,6 +705,48 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_MaxDailyAICreditsN } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_SandboxAgentPlatform(t *testing.T) { + t.Parallel() + + t.Run("valid platform is accepted", func(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "on": "push", + "sandbox": map[string]any{ + "agent": map[string]any{ + "id": "awf", + "platform": "ghes", + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-platform-ghes-test.md") + if err != nil { + t.Fatalf("expected sandbox.agent.platform=ghes to pass schema validation, got: %v", err) + } + }) + + t.Run("unknown platform is rejected", func(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "on": "push", + "sandbox": map[string]any{ + "agent": map[string]any{ + "id": "awf", + "platform": "github-enterprise", + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-platform-invalid-test.md") + if err == nil { + t.Fatal("expected sandbox.agent.platform=github-enterprise to fail schema validation") + } + }) +} + func TestMainWorkflowSchema_WorkflowDispatchNumberTypeDocumentation(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d8a83d0f8f1..2ee5dddfba1 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3252,6 +3252,11 @@ "type": "string", "description": "AWF version override used to install and run the matching firewall version." }, + "platform": { + "type": "string", + "enum": ["github.com", "ghes", "ghec", "ghec-self-hosted"], + "description": "AWF platform.type override. Declares the GitHub deployment type so AWF can apply deterministic Copilot auth behavior without relying on host heuristics. Omit to let AWF use its default host heuristic behavior." + }, "command": { "type": "string", "x-internal": true, diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 473b63a62d4..c14ed72abe1 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -162,6 +162,9 @@ type AWFConfigFile struct { // Network contains network egress control configuration. Network *AWFNetworkConfig `json:"network,omitempty"` + // Platform contains GitHub deployment metadata used by AWF auth handling. + Platform *AWFPlatformConfig `json:"platform,omitempty"` + // APIProxy contains API proxy (LLM gateway) configuration. APIProxy *AWFAPIProxyConfig `json:"apiProxy,omitempty"` @@ -186,6 +189,12 @@ type AWFNetworkConfig struct { BlockDomains []string `json:"blockDomains,omitempty"` } +// AWFPlatformConfig is the "platform" section of the AWF config file. +type AWFPlatformConfig struct { + // Type is the GitHub deployment type consumed by AWF for auth behavior. + Type string `json:"type,omitempty"` +} + // AWFAPIProxyConfig is the "apiProxy" section of the AWF config file. // It maps to the --enable-api-proxy and --*-api-target CLI flags. type AWFAPIProxyConfig struct { @@ -352,6 +361,11 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { } } + if platformType := extractPlatformType(config.WorkflowData); platformType != "" { + awfConfig.Platform = &AWFPlatformConfig{Type: platformType} + awfConfigLog.Printf("Platform section: type=%s", platformType) + } + // ── API proxy section ───────────────────────────────────────────────────── // maxAICredits is taken from frontmatter/imports only; when unset (0) the // runtime value is resolved from vars.GH_AW_DEFAULT_MAX_AI_CREDITS via a @@ -517,6 +531,21 @@ func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { return workflowData.EngineConfig.TokenWeights.Multipliers } +// extractPlatformType returns sandbox.agent.platform only for enabled AWF sandbox +// agents, or an empty string to let AWF fall back to its default platform logic. +func extractPlatformType(workflowData *WorkflowData) string { + if workflowData == nil || workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { + return "" + } + if workflowData.SandboxConfig.Agent.Disabled { + return "" + } + if !isSupportedSandboxType(getAgentType(workflowData.SandboxConfig.Agent)) { + return "" + } + return workflowData.SandboxConfig.Agent.Platform +} + // extractModelFallback returns an AWFModelFallbackConfig if the workflow has configured // sandbox.agent.model-fallback, or nil if the field is absent (letting AWF use its default). func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig { diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 37e1b168009..b228a28c99a 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -58,6 +58,30 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.Contains(t, jsonStr, `"imageTag"`, "should include imageTag") }) + t.Run("platform config is omitted when sandbox agent is disabled", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + Platform: "ghes", + Disabled: true, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"platform":`, "platform should be absent when sandbox agent is disabled") + }) + t.Run("blocked domains are included in the network section", func(t *testing.T) { config := AWFCommandConfig{ EngineName: "copilot", @@ -695,6 +719,51 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.NotContains(t, jsonStr, `"authHeader"`, "authHeader should be absent when not configured") }) + t.Run("sandbox agent platform is emitted in awf platform config", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + Platform: "ghes", + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"platform":{"type":"ghes"}`, "should include AWF platform.type when configured") + }) + + t.Run("platform config is omitted when sandbox agent platform is not configured", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"platform":`, "platform section should be absent when sandbox.agent.platform is unset") + }) + t.Run("model-fallback is emitted when enabled is explicitly set to false", func(t *testing.T) { disabled := TemplatableBool("false") config := AWFCommandConfig{ @@ -1196,6 +1265,31 @@ func TestBuildAWFCommand_UsesConfigFile(t *testing.T) { assert.Contains(t, command, `\"enabled\":true`, "config JSON should have apiProxy enabled") } +func TestBuildAWFCommand_EmbedsPlatformConfig(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + EngineCommand: "copilot --prompt-file /tmp/prompt.txt", + LogFile: "/tmp/gh-aw/agent-stdio.log", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Type: SandboxTypeAWF, + Platform: "ghes", + }, + }, + }, + } + + command := BuildAWFCommand(config) + + assert.Contains(t, command, `\"platform\":{\"type\":\"ghes\"}`, "expected awf-config JSON in command to include platform.type") +} + func TestBuildAWFCommand_ResolvesMaxAICreditsFromEnv(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 8c861b2ab28..e5480169459 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -197,6 +197,13 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract platform (AWF platform.type override) + if platformVal, hasPlatform := agentObj["platform"]; hasPlatform { + if platformStr, ok := platformVal.(string); ok { + agentConfig.Platform = platformStr + } + } + // Extract config for SRT if configVal, hasConfig := agentObj["config"]; hasConfig { agentConfig.Config = c.extractSRTConfig(configVal) diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 0835d123491..414df3ea474 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -24,6 +24,21 @@ func TestExtractAgentSandboxConfigVersion(t *testing.T) { }) } +func TestExtractAgentSandboxConfigPlatform(t *testing.T) { + compiler := &Compiler{} + + t.Run("extracts sandbox.agent.platform from object format", func(t *testing.T) { + agentObj := map[string]any{ + "id": "awf", + "platform": "ghes", + } + + config := compiler.extractAgentSandboxConfig(agentObj) + require.NotNil(t, config, "Should extract agent sandbox config") + assert.Equal(t, "ghes", config.Platform, "Should extract sandbox.agent.platform") + }) +} + func TestExtractAgentSandboxConfigModelFallback(t *testing.T) { compiler := &Compiler{} diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 49345fb7f61..477ac3ea9dc 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -258,7 +258,8 @@ func TestParseFrontmatterConfig(t *testing.T) { frontmatter := map[string]any{ "sandbox": map[string]any{ "agent": map[string]any{ - "type": "awf", + "type": "awf", + "platform": "ghes", }, }, } @@ -271,6 +272,12 @@ func TestParseFrontmatterConfig(t *testing.T) { if config.Sandbox == nil { t.Fatal("Sandbox should not be nil") } + if config.Sandbox.Agent == nil { + t.Fatal("Sandbox.Agent should not be nil") + } + if config.Sandbox.Agent.Platform != "ghes" { + t.Fatalf("Sandbox.Agent.Platform = %q, want %q", config.Sandbox.Agent.Platform, "ghes") + } }) t.Run("handles observability configuration", func(t *testing.T) { diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 45d021f19d0..fa3115f4e3d 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -49,6 +49,7 @@ 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 + Platform string `yaml:"platform,omitempty"` // AWF platform.type override (github.com, ghes, ghec, ghec-self-hosted) Disabled bool `yaml:"-"` // True when agent is explicitly set to false (disables firewall). This is a runtime flag, not serialized to YAML. DisableReason string `yaml:"-"` // Operator-authored justification from dangerously-disable-sandbox-agent feature; available for diagnostics and audit logging. Config *SandboxRuntimeConfig `yaml:"config,omitempty"` // Custom SRT config (optional)