Skip to content

Commit 9f0be69

Browse files
authored
Phase 4: Add AuthDefinition and RequestShape for provider-owned auth and request shaping (#20473)
1 parent 95f4259 commit 9f0be69

File tree

9 files changed

+1002
-10
lines changed

9 files changed

+1002
-10
lines changed

pkg/cli/engine_secrets.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,53 @@ func getEngineSecretDescription(opt *constants.EngineOption) string {
102102
}
103103
}
104104

105+
// secretRequirementsFromAuthDefinition converts an AuthDefinition into SecretRequirement
106+
// entries so that auth-binding secrets are treated as required secrets (same as built-in
107+
// engine secrets). Returns nil when auth is nil.
108+
func secretRequirementsFromAuthDefinition(auth *workflow.AuthDefinition, engineName string) []SecretRequirement {
109+
if auth == nil {
110+
return nil
111+
}
112+
113+
var reqs []SecretRequirement
114+
115+
switch auth.Strategy {
116+
case workflow.AuthStrategyOAuthClientCreds:
117+
// OAuth client-credentials flow: require client-id and client-secret secrets.
118+
if auth.ClientIDRef != "" {
119+
reqs = append(reqs, SecretRequirement{
120+
Name: auth.ClientIDRef,
121+
WhenNeeded: fmt.Sprintf("OAuth client ID for %s engine", engineName),
122+
Description: "GitHub Actions secret holding the OAuth 2.0 client ID used to obtain access tokens.",
123+
IsEngineSecret: true,
124+
EngineName: engineName,
125+
})
126+
}
127+
if auth.ClientSecretRef != "" {
128+
reqs = append(reqs, SecretRequirement{
129+
Name: auth.ClientSecretRef,
130+
WhenNeeded: fmt.Sprintf("OAuth client secret for %s engine", engineName),
131+
Description: "GitHub Actions secret holding the OAuth 2.0 client secret used to obtain access tokens.",
132+
IsEngineSecret: true,
133+
EngineName: engineName,
134+
})
135+
}
136+
default:
137+
// api-key, bearer, or unset strategy: require the direct secret.
138+
if auth.Secret != "" {
139+
reqs = append(reqs, SecretRequirement{
140+
Name: auth.Secret,
141+
WhenNeeded: fmt.Sprintf("API key or token for %s engine", engineName),
142+
Description: "GitHub Actions secret holding the API key or bearer token for provider authentication.",
143+
IsEngineSecret: true,
144+
EngineName: engineName,
145+
})
146+
}
147+
}
148+
149+
return reqs
150+
}
151+
105152
// getMissingRequiredSecrets filters requirements to return only missing required secrets.
106153
// It skips optional secrets and checks both primary and alternative secret names.
107154
func getMissingRequiredSecrets(requirements []SecretRequirement, existingSecrets map[string]bool) []SecretRequirement {

pkg/cli/workflow_secrets.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package cli
22

33
import (
4+
"fmt"
5+
"os"
6+
47
"github.com/github/gh-aw/pkg/constants"
58
"github.com/github/gh-aw/pkg/logger"
9+
"github.com/github/gh-aw/pkg/parser"
10+
"github.com/github/gh-aw/pkg/workflow"
611
)
712

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

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

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

6672
// Get engine-specific secrets only (no system secrets, no optional)
6773
// System secrets will be added separately to avoid duplication
68-
return getSecretRequirementsForEngine(engine, true, true)
74+
reqs := getSecretRequirementsForEngine(engine, false, false)
75+
76+
// For inline engine definitions with an AuthDefinition, also include auth secrets.
77+
if engineConfig != nil && engineConfig.InlineProviderAuth != nil {
78+
authReqs := secretRequirementsFromAuthDefinition(engineConfig.InlineProviderAuth, engine)
79+
workflowSecretsLog.Printf("Adding %d auth definition secret(s) for workflow %s", len(authReqs), workflowFile)
80+
reqs = append(reqs, authReqs...)
81+
}
82+
83+
return reqs
84+
}
85+
86+
// extractEngineConfigFromFile parses a workflow file and returns the engine ID and config.
87+
// Returns ("", nil) when the file cannot be read or parsed.
88+
func extractEngineConfigFromFile(filePath string) (string, *workflow.EngineConfig) {
89+
content, err := readWorkflowFileContent(filePath)
90+
if err != nil {
91+
return "", nil
92+
}
93+
94+
result, err := parser.ExtractFrontmatterFromContent(content)
95+
if err != nil {
96+
return "", nil
97+
}
98+
99+
compiler := &workflow.Compiler{}
100+
engineSetting, engineConfig := compiler.ExtractEngineConfig(result.Frontmatter)
101+
102+
if engineConfig != nil && engineConfig.ID != "" {
103+
return engineConfig.ID, engineConfig
104+
}
105+
if engineSetting != "" {
106+
return engineSetting, engineConfig
107+
}
108+
return "copilot", engineConfig // Default engine
109+
}
110+
111+
// readWorkflowFileContent reads a workflow file's content as a string.
112+
func readWorkflowFileContent(filePath string) (string, error) {
113+
content, err := os.ReadFile(filePath)
114+
if err != nil {
115+
return "", fmt.Errorf("reading workflow file %s: %w", filePath, err)
116+
}
117+
return string(content), nil
69118
}

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7799,6 +7799,28 @@
77997799
"provider": {
78007800
"model": "claude-3-7-sonnet-20250219"
78017801
}
7802+
},
7803+
{
7804+
"runtime": {
7805+
"id": "codex"
7806+
},
7807+
"provider": {
7808+
"id": "azure-openai",
7809+
"model": "gpt-4o",
7810+
"auth": {
7811+
"strategy": "oauth-client-credentials",
7812+
"token-url": "https://auth.example.com/oauth/token",
7813+
"client-id": "AZURE_CLIENT_ID",
7814+
"client-secret": "AZURE_CLIENT_SECRET",
7815+
"header-name": "api-key"
7816+
},
7817+
"request": {
7818+
"path-template": "/openai/deployments/{model}/chat/completions",
7819+
"query": {
7820+
"api-version": "2024-10-01-preview"
7821+
}
7822+
}
7823+
}
78027824
}
78037825
],
78047826
"oneOf": [
@@ -7978,6 +8000,72 @@
79788000
"type": "string",
79798001
"description": "Name of the GitHub Actions secret that contains the API key for this provider",
79808002
"examples": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CUSTOM_API_KEY"]
8003+
},
8004+
"strategy": {
8005+
"type": "string",
8006+
"enum": ["api-key", "oauth-client-credentials", "bearer"],
8007+
"description": "Authentication strategy for the provider (default: api-key when secret is set)"
8008+
},
8009+
"token-url": {
8010+
"type": "string",
8011+
"description": "OAuth 2.0 token endpoint URL. Required when strategy is 'oauth-client-credentials'.",
8012+
"examples": ["https://auth.example.com/oauth/token"]
8013+
},
8014+
"client-id": {
8015+
"type": "string",
8016+
"description": "GitHub Actions secret name that holds the OAuth client ID. Required when strategy is 'oauth-client-credentials'.",
8017+
"examples": ["OAUTH_CLIENT_ID"]
8018+
},
8019+
"client-secret": {
8020+
"type": "string",
8021+
"description": "GitHub Actions secret name that holds the OAuth client secret. Required when strategy is 'oauth-client-credentials'.",
8022+
"examples": ["OAUTH_CLIENT_SECRET"]
8023+
},
8024+
"token-field": {
8025+
"type": "string",
8026+
"description": "JSON field name in the token response that contains the access token. Defaults to 'access_token'.",
8027+
"examples": ["access_token", "token"]
8028+
},
8029+
"header-name": {
8030+
"type": "string",
8031+
"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'.",
8032+
"examples": ["api-key", "x-api-key", "Authorization"]
8033+
}
8034+
},
8035+
"additionalProperties": false
8036+
},
8037+
"request": {
8038+
"type": "object",
8039+
"description": "Request shaping configuration for non-standard provider URL and body transformations",
8040+
"properties": {
8041+
"path-template": {
8042+
"type": "string",
8043+
"description": "URL path template with {model} and other variable placeholders (e.g. '/openai/deployments/{model}/chat/completions')",
8044+
"examples": ["/openai/deployments/{model}/chat/completions"]
8045+
},
8046+
"query": {
8047+
"type": "object",
8048+
"description": "Static or template query-parameter values appended to every request",
8049+
"additionalProperties": {
8050+
"type": "string"
8051+
},
8052+
"examples": [
8053+
{
8054+
"api-version": "2024-10-01-preview"
8055+
}
8056+
]
8057+
},
8058+
"body-inject": {
8059+
"type": "object",
8060+
"description": "Key/value pairs injected into the JSON request body before sending",
8061+
"additionalProperties": {
8062+
"type": "string"
8063+
},
8064+
"examples": [
8065+
{
8066+
"appKey": "{APP_KEY_SECRET}"
8067+
}
8068+
]
79818069
}
79828070
},
79838071
"additionalProperties": false

pkg/workflow/compiler_orchestrator_engine.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean
4242
if err := c.validateEngineInlineDefinition(engineConfig); err != nil {
4343
return nil, err
4444
}
45+
if err := c.validateEngineAuthDefinition(engineConfig); err != nil {
46+
return nil, err
47+
}
4548
c.registerInlineEngineDefinition(engineConfig)
4649
}
4750

pkg/workflow/engine.go

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ type EngineConfig struct {
2828
Agent string // Agent identifier for copilot --agent flag (copilot engine only)
2929

3030
// Inline definition fields (populated when engine.runtime is specified in frontmatter)
31-
IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider
32-
InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic")
33-
InlineProviderSecret string // engine.provider.auth.secret (name of the GitHub Actions secret for the provider API key)
31+
IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider
32+
InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic")
33+
// Deprecated: Use InlineProviderAuth instead. Kept for backwards compatibility when only
34+
// engine.provider.auth.secret is specified without a strategy.
35+
InlineProviderSecret string // engine.provider.auth.secret (backwards compat: simple API key secret name)
36+
37+
// Extended inline auth fields (engine.provider.auth.* beyond the simple secret)
38+
InlineProviderAuth *AuthDefinition // full auth definition parsed from engine.provider.auth
39+
40+
// Extended inline request shaping fields (engine.provider.request.*)
41+
InlineProviderRequest *RequestShape // request shaping parsed from engine.provider.request
3442
}
3543

3644
// NetworkPermissions represents network access permissions for workflow execution
@@ -118,11 +126,25 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
118126
}
119127
if auth, hasAuth := providerObj["auth"]; hasAuth {
120128
if authObj, ok := auth.(map[string]any); ok {
121-
if secret, ok := authObj["secret"].(string); ok {
122-
config.InlineProviderSecret = secret
129+
authDef := parseAuthDefinition(authObj)
130+
// Only store an AuthDefinition when the user actually provided
131+
// at least one recognised field. An empty map (e.g. `auth: {}`)
132+
// must not be treated as an explicit auth override.
133+
if authDef.Strategy != "" || authDef.Secret != "" ||
134+
authDef.TokenURL != "" || authDef.ClientIDRef != "" ||
135+
authDef.ClientSecretRef != "" || authDef.HeaderName != "" ||
136+
authDef.TokenField != "" {
137+
config.InlineProviderAuth = authDef
138+
// Backwards compat: expose the simple secret field directly.
139+
config.InlineProviderSecret = authDef.Secret
123140
}
124141
}
125142
}
143+
if request, hasRequest := providerObj["request"]; hasRequest {
144+
if requestObj, ok := request.(map[string]any); ok {
145+
config.InlineProviderRequest = parseRequestShape(requestObj)
146+
}
147+
}
126148
}
127149
}
128150

@@ -351,3 +373,58 @@ func (c *Compiler) extractEngineConfigFromJSON(engineJSON string) (*EngineConfig
351373
_, config := c.ExtractEngineConfig(tempFrontmatter)
352374
return config, nil
353375
}
376+
377+
// parseAuthDefinition converts a raw auth config map (from engine.provider.auth) into
378+
// an AuthDefinition. It is backward-compatible: a map with only a "secret" key produces
379+
// an AuthDefinition with Strategy="" and Secret set (callers normalise Strategy to api-key).
380+
func parseAuthDefinition(authObj map[string]any) *AuthDefinition {
381+
def := &AuthDefinition{}
382+
if s, ok := authObj["strategy"].(string); ok {
383+
def.Strategy = AuthStrategy(s)
384+
}
385+
if s, ok := authObj["secret"].(string); ok {
386+
def.Secret = s
387+
}
388+
if s, ok := authObj["token-url"].(string); ok {
389+
def.TokenURL = s
390+
}
391+
if s, ok := authObj["client-id"].(string); ok {
392+
def.ClientIDRef = s
393+
}
394+
if s, ok := authObj["client-secret"].(string); ok {
395+
def.ClientSecretRef = s
396+
}
397+
if s, ok := authObj["token-field"].(string); ok {
398+
def.TokenField = s
399+
}
400+
if s, ok := authObj["header-name"].(string); ok {
401+
def.HeaderName = s
402+
}
403+
return def
404+
}
405+
406+
// parseRequestShape converts a raw request config map (from engine.provider.request) into
407+
// a RequestShape.
408+
func parseRequestShape(requestObj map[string]any) *RequestShape {
409+
shape := &RequestShape{}
410+
if s, ok := requestObj["path-template"].(string); ok {
411+
shape.PathTemplate = s
412+
}
413+
if q, ok := requestObj["query"].(map[string]any); ok {
414+
shape.Query = make(map[string]string, len(q))
415+
for k, v := range q {
416+
if vs, ok := v.(string); ok {
417+
shape.Query[k] = vs
418+
}
419+
}
420+
}
421+
if b, ok := requestObj["body-inject"].(map[string]any); ok {
422+
shape.BodyInject = make(map[string]string, len(b))
423+
for k, v := range b {
424+
if vs, ok := v.(string); ok {
425+
shape.BodyInject[k] = vs
426+
}
427+
}
428+
}
429+
return shape
430+
}

0 commit comments

Comments
 (0)