Skip to content
Merged
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
47 changes: 47 additions & 0 deletions pkg/cli/engine_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,53 @@ func getEngineSecretDescription(opt *constants.EngineOption) string {
}
}

// secretRequirementsFromAuthDefinition converts an AuthDefinition into SecretRequirement
// entries so that auth-binding secrets are treated as required secrets (same as built-in
// engine secrets). Returns nil when auth is nil.
func secretRequirementsFromAuthDefinition(auth *workflow.AuthDefinition, engineName string) []SecretRequirement {
if auth == nil {
return nil
}

var reqs []SecretRequirement

switch auth.Strategy {
case workflow.AuthStrategyOAuthClientCreds:
// OAuth client-credentials flow: require client-id and client-secret secrets.
if auth.ClientIDRef != "" {
reqs = append(reqs, SecretRequirement{
Name: auth.ClientIDRef,
WhenNeeded: fmt.Sprintf("OAuth client ID for %s engine", engineName),
Description: "GitHub Actions secret holding the OAuth 2.0 client ID used to obtain access tokens.",
IsEngineSecret: true,
EngineName: engineName,
})
}
if auth.ClientSecretRef != "" {
reqs = append(reqs, SecretRequirement{
Name: auth.ClientSecretRef,
WhenNeeded: fmt.Sprintf("OAuth client secret for %s engine", engineName),
Description: "GitHub Actions secret holding the OAuth 2.0 client secret used to obtain access tokens.",
IsEngineSecret: true,
EngineName: engineName,
})
}
default:
// api-key, bearer, or unset strategy: require the direct secret.
if auth.Secret != "" {
reqs = append(reqs, SecretRequirement{
Name: auth.Secret,
WhenNeeded: fmt.Sprintf("API key or token for %s engine", engineName),
Description: "GitHub Actions secret holding the API key or bearer token for provider authentication.",
IsEngineSecret: true,
EngineName: engineName,
})
}
}

return reqs
}

// getMissingRequiredSecrets filters requirements to return only missing required secrets.
// It skips optional secrets and checks both primary and alternative secret names.
func getMissingRequiredSecrets(requirements []SecretRequirement, existingSecrets map[string]bool) []SecretRequirement {
Expand Down
55 changes: 52 additions & 3 deletions pkg/cli/workflow_secrets.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package cli

import (
"fmt"
"os"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
"github.com/github/gh-aw/pkg/workflow"
)

var workflowSecretsLog = logger.New("cli:workflow_secrets")
Expand Down Expand Up @@ -45,7 +50,8 @@ func getSecretsRequirementsForWorkflows(workflowFiles []string) []SecretRequirem
return allRequirements
}

// getSecretRequirementsForWorkflow extracts the engine from a workflow file and returns its required secrets
// getSecretRequirementsForWorkflow extracts the engine from a workflow file and returns its required secrets.
// It also extracts AuthDefinition secrets from inline engine definitions.
//
// NOTE: In future we will want to analyse more parts of the
// workflow to work out other secrets required, or detect that the particular
Expand All @@ -55,7 +61,7 @@ func getSecretRequirementsForWorkflow(workflowFile string) []SecretRequirement {
workflowSecretsLog.Printf("Extracting secrets for workflow: %s", workflowFile)

// Extract engine from workflow file
engine := extractEngineIDFromFile(workflowFile)
engine, engineConfig := extractEngineConfigFromFile(workflowFile)
if engine == "" {
workflowSecretsLog.Printf("No engine found in workflow %s, skipping", workflowFile)
return nil
Expand All @@ -65,5 +71,48 @@ func getSecretRequirementsForWorkflow(workflowFile string) []SecretRequirement {

// Get engine-specific secrets only (no system secrets, no optional)
// System secrets will be added separately to avoid duplication
return getSecretRequirementsForEngine(engine, true, true)
reqs := getSecretRequirementsForEngine(engine, false, false)

// For inline engine definitions with an AuthDefinition, also include auth secrets.
if engineConfig != nil && engineConfig.InlineProviderAuth != nil {
authReqs := secretRequirementsFromAuthDefinition(engineConfig.InlineProviderAuth, engine)
workflowSecretsLog.Printf("Adding %d auth definition secret(s) for workflow %s", len(authReqs), workflowFile)
reqs = append(reqs, authReqs...)
}

return reqs
}

// extractEngineConfigFromFile parses a workflow file and returns the engine ID and config.
// Returns ("", nil) when the file cannot be read or parsed.
func extractEngineConfigFromFile(filePath string) (string, *workflow.EngineConfig) {
content, err := readWorkflowFileContent(filePath)
if err != nil {
return "", nil
}

result, err := parser.ExtractFrontmatterFromContent(content)
if err != nil {
return "", nil
}

compiler := &workflow.Compiler{}
engineSetting, engineConfig := compiler.ExtractEngineConfig(result.Frontmatter)

if engineConfig != nil && engineConfig.ID != "" {
return engineConfig.ID, engineConfig
}
if engineSetting != "" {
return engineSetting, engineConfig
}
return "copilot", engineConfig // Default engine
}

// readWorkflowFileContent reads a workflow file's content as a string.
func readWorkflowFileContent(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("reading workflow file %s: %w", filePath, err)
}
return string(content), nil
}
88 changes: 88 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7799,6 +7799,28 @@
"provider": {
"model": "claude-3-7-sonnet-20250219"
}
},
{
"runtime": {
"id": "codex"
},
"provider": {
"id": "azure-openai",
"model": "gpt-4o",
"auth": {
"strategy": "oauth-client-credentials",
"token-url": "https://auth.example.com/oauth/token",
"client-id": "AZURE_CLIENT_ID",
"client-secret": "AZURE_CLIENT_SECRET",
"header-name": "api-key"
},
"request": {
"path-template": "/openai/deployments/{model}/chat/completions",
"query": {
"api-version": "2024-10-01-preview"
}
}
}
}
],
"oneOf": [
Expand Down Expand Up @@ -7978,6 +8000,72 @@
"type": "string",
"description": "Name of the GitHub Actions secret that contains the API key for this provider",
"examples": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CUSTOM_API_KEY"]
},
"strategy": {
"type": "string",
"enum": ["api-key", "oauth-client-credentials", "bearer"],
"description": "Authentication strategy for the provider (default: api-key when secret is set)"
},
"token-url": {
"type": "string",
"description": "OAuth 2.0 token endpoint URL. Required when strategy is 'oauth-client-credentials'.",
"examples": ["https://auth.example.com/oauth/token"]
},
"client-id": {
"type": "string",
"description": "GitHub Actions secret name that holds the OAuth client ID. Required when strategy is 'oauth-client-credentials'.",
"examples": ["OAUTH_CLIENT_ID"]
},
"client-secret": {
"type": "string",
"description": "GitHub Actions secret name that holds the OAuth client secret. Required when strategy is 'oauth-client-credentials'.",
"examples": ["OAUTH_CLIENT_SECRET"]
},
"token-field": {
"type": "string",
"description": "JSON field name in the token response that contains the access token. Defaults to 'access_token'.",
"examples": ["access_token", "token"]
},
"header-name": {
"type": "string",
"description": "HTTP header name to inject the API key or token into (e.g. 'api-key', 'x-api-key'). Required when strategy is not 'bearer'.",
"examples": ["api-key", "x-api-key", "Authorization"]
}
},
"additionalProperties": false
},
"request": {
"type": "object",
"description": "Request shaping configuration for non-standard provider URL and body transformations",
"properties": {
"path-template": {
"type": "string",
"description": "URL path template with {model} and other variable placeholders (e.g. '/openai/deployments/{model}/chat/completions')",
"examples": ["/openai/deployments/{model}/chat/completions"]
},
"query": {
"type": "object",
"description": "Static or template query-parameter values appended to every request",
"additionalProperties": {
"type": "string"
},
"examples": [
{
"api-version": "2024-10-01-preview"
}
]
},
"body-inject": {
"type": "object",
"description": "Key/value pairs injected into the JSON request body before sending",
"additionalProperties": {
"type": "string"
},
"examples": [
{
"appKey": "{APP_KEY_SECRET}"
}
]
}
},
"additionalProperties": false
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/compiler_orchestrator_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean
if err := c.validateEngineInlineDefinition(engineConfig); err != nil {
return nil, err
}
if err := c.validateEngineAuthDefinition(engineConfig); err != nil {
return nil, err
}
c.registerInlineEngineDefinition(engineConfig)
}

Expand Down
87 changes: 82 additions & 5 deletions pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ type EngineConfig struct {
Agent string // Agent identifier for copilot --agent flag (copilot engine only)

// 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")
InlineProviderSecret string // engine.provider.auth.secret (name of the GitHub Actions secret for the provider API key)
IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider
InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic")
// Deprecated: Use InlineProviderAuth instead. Kept for backwards compatibility when only
// engine.provider.auth.secret is specified without a strategy.
InlineProviderSecret string // engine.provider.auth.secret (backwards compat: simple API key secret name)

// Extended inline auth fields (engine.provider.auth.* beyond the simple secret)
InlineProviderAuth *AuthDefinition // full auth definition parsed from engine.provider.auth

// Extended inline request shaping fields (engine.provider.request.*)
InlineProviderRequest *RequestShape // request shaping parsed from engine.provider.request
}

// NetworkPermissions represents network access permissions for workflow execution
Expand Down Expand Up @@ -118,11 +126,25 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
}
if auth, hasAuth := providerObj["auth"]; hasAuth {
if authObj, ok := auth.(map[string]any); ok {
if secret, ok := authObj["secret"].(string); ok {
config.InlineProviderSecret = secret
authDef := parseAuthDefinition(authObj)
// Only store an AuthDefinition when the user actually provided
// at least one recognised field. An empty map (e.g. `auth: {}`)
// must not be treated as an explicit auth override.
if authDef.Strategy != "" || authDef.Secret != "" ||
authDef.TokenURL != "" || authDef.ClientIDRef != "" ||
authDef.ClientSecretRef != "" || authDef.HeaderName != "" ||
authDef.TokenField != "" {
config.InlineProviderAuth = authDef
// Backwards compat: expose the simple secret field directly.
config.InlineProviderSecret = authDef.Secret
}
}
Comment on lines 127 to +141
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When engine.provider.auth is present, ExtractEngineConfig always sets InlineProviderAuth to a non-nil (possibly empty) AuthDefinition. That makes downstream code treat auth as “explicitly configured” even when the user provided {} or only unknown keys. To avoid surprising overrides, consider only setting InlineProviderAuth when at least one recognized field is non-empty, or have registration/validation treat an all-zero AuthDefinition as nil.

Copilot uses AI. Check for mistakes.
}
if request, hasRequest := providerObj["request"]; hasRequest {
if requestObj, ok := request.(map[string]any); ok {
config.InlineProviderRequest = parseRequestShape(requestObj)
}
}
}
}

Expand Down Expand Up @@ -351,3 +373,58 @@ func (c *Compiler) extractEngineConfigFromJSON(engineJSON string) (*EngineConfig
_, config := c.ExtractEngineConfig(tempFrontmatter)
return config, nil
}

// parseAuthDefinition converts a raw auth config map (from engine.provider.auth) into
// an AuthDefinition. It is backward-compatible: a map with only a "secret" key produces
// an AuthDefinition with Strategy="" and Secret set (callers normalise Strategy to api-key).
func parseAuthDefinition(authObj map[string]any) *AuthDefinition {
def := &AuthDefinition{}
if s, ok := authObj["strategy"].(string); ok {
def.Strategy = AuthStrategy(s)
}
if s, ok := authObj["secret"].(string); ok {
def.Secret = s
}
if s, ok := authObj["token-url"].(string); ok {
def.TokenURL = s
}
if s, ok := authObj["client-id"].(string); ok {
def.ClientIDRef = s
}
if s, ok := authObj["client-secret"].(string); ok {
def.ClientSecretRef = s
}
if s, ok := authObj["token-field"].(string); ok {
def.TokenField = s
}
if s, ok := authObj["header-name"].(string); ok {
def.HeaderName = s
}
return def
}

// parseRequestShape converts a raw request config map (from engine.provider.request) into
// a RequestShape.
func parseRequestShape(requestObj map[string]any) *RequestShape {
shape := &RequestShape{}
if s, ok := requestObj["path-template"].(string); ok {
shape.PathTemplate = s
}
if q, ok := requestObj["query"].(map[string]any); ok {
shape.Query = make(map[string]string, len(q))
for k, v := range q {
if vs, ok := v.(string); ok {
shape.Query[k] = vs
}
}
}
if b, ok := requestObj["body-inject"].(map[string]any); ok {
shape.BodyInject = make(map[string]string, len(b))
for k, v := range b {
if vs, ok := v.(string); ok {
shape.BodyInject[k] = vs
}
}
}
return shape
}
Loading
Loading