Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,39 @@ Set `COPILOT_PROVIDER_BASE_URL` in `engine.env` to activate BYOK mode. The crede
| `COPILOT_PROVIDER_MAX_PROMPT_TOKENS` | Optional | Override the maximum prompt token limit (otherwise resolved from model catalog) |
| `COPILOT_PROVIDER_MAX_OUTPUT_TOKENS` | Optional | Override the maximum output token limit |

#### OIDC authentication for BYOK (`engine.auth`)

For providers that require short-lived OIDC tokens instead of static keys (for example Azure OpenAI with Entra-only auth), set `engine.auth.type: github-oidc`.

This configuration is translated to AWF api-proxy auth settings at runtime:

| `engine.auth` field | AWF setting |
|---|---|
| `type` | `AWF_AUTH_TYPE` |
| `audience` | `AWF_AUTH_AUDIENCE` |

`type` currently supports only `github-oidc`.

```yaml wrap
permissions:
id-token: write

engine:
id: copilot
auth:
type: github-oidc
audience: https://cognitiveservices.azure.com
env:
COPILOT_PROVIDER_BASE_URL: https://my-resource.openai.azure.com
COPILOT_PROVIDER_TYPE: azure
COPILOT_MODEL: gpt-4.1
```

`audience` is optional. When omitted, the provider-side default audience behavior applies.

> [!IMPORTANT]
> OIDC BYOK requires `permissions: { id-token: write }` so GitHub Actions exposes `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` for token acquisition.

**Example: OpenAI-compatible provider**

```yaml wrap
Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10106,6 +10106,10 @@
}
},
"additionalProperties": false
},
"auth": {
"$ref": "#/$defs/http_mcp_auth",
"description": "Authentication configuration for BYOK model-provider requests routed through the AWF api-proxy sidecar."
}
},
"required": ["id"],
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ touch %s
if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 {
maps.Copy(env, workflowData.EngineConfig.Env)
}
if workflowData.EngineConfig != nil && workflowData.EngineConfig.Auth != nil {
if workflowData.EngineConfig.Auth.Type != "" {
env["AWF_AUTH_TYPE"] = workflowData.EngineConfig.Auth.Type
}
if workflowData.EngineConfig.Auth.Audience != "" {
env["AWF_AUTH_AUDIENCE"] = workflowData.EngineConfig.Auth.Audience
}
}

// Add custom environment variables from agent config
agentConfig := getAgentConfig(workflowData)
Expand Down
28 changes: 28 additions & 0 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/testutil"
"github.com/github/gh-aw/pkg/types"
)

func TestCopilotEngine(t *testing.T) {
Expand Down Expand Up @@ -1775,6 +1776,33 @@ func TestCopilotEngineEnvOverridesTokenExpression(t *testing.T) {
t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent)
}
})

t.Run("engine auth maps to AWF auth environment variables", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "copilot",
Auth: &types.MCPAuthConfig{
Type: "github-oidc",
Audience: "https://cognitiveservices.azure.com",
},
},
}

steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log")
if len(steps) != 1 {
t.Fatalf("Expected 1 step, got %d", len(steps))
}

stepContent := strings.Join([]string(steps[0]), "\n")

if !strings.Contains(stepContent, "AWF_AUTH_TYPE: github-oidc") {
t.Errorf("Expected engine.auth.type to set AWF_AUTH_TYPE, got:\n%s", stepContent)
}
if !strings.Contains(stepContent, "AWF_AUTH_AUDIENCE: https://cognitiveservices.azure.com") {
t.Errorf("Expected engine.auth.audience to set AWF_AUTH_AUDIENCE, got:\n%s", stepContent)
}
})
}

func TestCopilotEngineSetsDummyAPIKey(t *testing.T) {
Expand Down
19 changes: 19 additions & 0 deletions pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type EngineConfig struct {
Command string // Custom executable path (when set, skip installation steps)
HarnessScript string // Custom Node.js harness script filename (replaces engine default harness script when supported)
Env map[string]string
Auth *types.MCPAuthConfig // engine.auth (e.g., type: github-oidc, audience: ...)
Config string
Args []string
Agent string // Agent identifier for copilot --agent flag (copilot engine only)
Expand Down Expand Up @@ -261,6 +262,24 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
}
}

// Extract optional 'auth' field for BYOK provider auth configuration
if authVal, hasAuth := engineObj["auth"]; hasAuth {
if authObj, ok := authVal.(map[string]any); ok {
authConfig := &types.MCPAuthConfig{}
if authType, ok := authObj["type"].(string); ok {
authConfig.Type = authType
}
if audience, ok := authObj["audience"].(string); ok {
authConfig.Audience = audience
}
if authConfig.Type != "" {
config.Auth = authConfig
}
} else if authCfg, ok := authVal.(*types.MCPAuthConfig); ok {
config.Auth = authCfg
}
}

// Extract optional 'env' field (object/map of strings)
if env, hasEnv := engineObj["env"]; hasEnv {
if envMap, ok := env.(map[string]any); ok {
Expand Down
83 changes: 83 additions & 0 deletions pkg/workflow/engine_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/testutil"
"github.com/github/gh-aw/pkg/types"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -105,6 +106,26 @@ func TestExtractEngineConfig(t *testing.T) {
expectedEngineSetting: "codex",
expectedConfig: &EngineConfig{ID: "codex", Model: "gpt-4o"},
},
{
name: "object format - with engine auth",
frontmatter: map[string]any{
"engine": map[string]any{
"id": "copilot",
"auth": map[string]any{
"type": "github-oidc",
"audience": "https://cognitiveservices.azure.com",
},
},
},
expectedEngineSetting: "copilot",
expectedConfig: &EngineConfig{
ID: "copilot",
Auth: &types.MCPAuthConfig{
Type: "github-oidc",
Audience: "https://cognitiveservices.azure.com",
},
},
},
{
name: "object format - complete",
frontmatter: map[string]any{
Expand Down Expand Up @@ -279,6 +300,23 @@ func TestExtractEngineConfig(t *testing.T) {
t.Errorf("Expected config.HarnessScript '%s', got '%s'", test.expectedConfig.HarnessScript, config.HarnessScript)
}

if test.expectedConfig.Auth == nil {
if config.Auth != nil {
t.Errorf("Expected config.Auth to be nil, got %+v", config.Auth)
}
} else {
if config.Auth == nil {
t.Errorf("Expected config.Auth %+v, got nil", test.expectedConfig.Auth)
} else {
if config.Auth.Type != test.expectedConfig.Auth.Type {
t.Errorf("Expected config.Auth.Type '%s', got '%s'", test.expectedConfig.Auth.Type, config.Auth.Type)
}
if config.Auth.Audience != test.expectedConfig.Auth.Audience {
t.Errorf("Expected config.Auth.Audience '%s', got '%s'", test.expectedConfig.Auth.Audience, config.Auth.Audience)
}
}
}

if len(config.Env) != len(test.expectedConfig.Env) {
t.Errorf("Expected config.Env length %d, got %d", len(test.expectedConfig.Env), len(config.Env))
} else {
Expand Down Expand Up @@ -324,6 +362,35 @@ This is a test workflow.`,
expectedAI: "claude",
expectedConfig: &EngineConfig{ID: "claude"},
},
{
name: "object engine format - copilot with oidc auth",
content: `---
on: push
permissions:
contents: read
issues: read
pull-requests: read
id-token: write
strict: false
engine:
id: copilot
auth:
type: github-oidc
audience: https://cognitiveservices.azure.com
---

# Test Workflow

This is a test workflow.`,
expectedAI: "copilot",
expectedConfig: &EngineConfig{
ID: "copilot",
Auth: &types.MCPAuthConfig{
Type: "github-oidc",
Audience: "https://cognitiveservices.azure.com",
},
},
},
{
name: "object engine format - complete",
content: `---
Expand Down Expand Up @@ -407,6 +474,22 @@ This is a test workflow.`,
if workflowData.EngineConfig.Model != test.expectedConfig.Model {
t.Errorf("Expected EngineConfig.Model '%s', got '%s'", test.expectedConfig.Model, workflowData.EngineConfig.Model)
}
if test.expectedConfig.Auth == nil {
if workflowData.EngineConfig.Auth != nil {
t.Errorf("Expected EngineConfig.Auth to be nil, got %+v", workflowData.EngineConfig.Auth)
}
} else {
if workflowData.EngineConfig.Auth == nil {
t.Errorf("Expected EngineConfig.Auth %+v, got nil", test.expectedConfig.Auth)
} else {
if workflowData.EngineConfig.Auth.Type != test.expectedConfig.Auth.Type {
t.Errorf("Expected EngineConfig.Auth.Type '%s', got '%s'", test.expectedConfig.Auth.Type, workflowData.EngineConfig.Auth.Type)
}
if workflowData.EngineConfig.Auth.Audience != test.expectedConfig.Auth.Audience {
t.Errorf("Expected EngineConfig.Auth.Audience '%s', got '%s'", test.expectedConfig.Auth.Audience, workflowData.EngineConfig.Auth.Audience)
}
}
}
}
})
}
Expand Down