diff --git a/docs/adr/48096-expose-byok-extra-fields-in-copilot-frontmatter.md b/docs/adr/48096-expose-byok-extra-fields-in-copilot-frontmatter.md new file mode 100644 index 00000000000..ce058500380 --- /dev/null +++ b/docs/adr/48096-expose-byok-extra-fields-in-copilot-frontmatter.md @@ -0,0 +1,43 @@ +# ADR-48096: Expose BYOK extraHeaders, extraBodyFields, and sessionId in Copilot Frontmatter + +**Date**: 2026-07-26 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +The AWF proxy sidecar already accepts three Copilot BYOK-specific injection parameters — `AWF_BYOK_EXTRA_HEADERS`, `AWF_BYOK_EXTRA_BODY_FIELDS`, and `AWF_PROVIDER_SESSION_ID` — that let operators append custom HTTP headers, JSON body fields, and a session identifier to upstream Copilot BYOK requests. gh-aw provided no frontmatter surface for these parameters, so workflow authors who needed them (e.g., to route through OpenRouter with required `x-openrouter-title` or `http-referer` headers) had to hand-edit the generated AWF JSON config. This hand-edited config was silently overwritten on every `gh aw compile`, making the workaround fragile and unsupported. + +### Decision + +We will expose `extraHeaders`, `extraBodyFields`, and `sessionId` as explicit fields in the `sandbox.agent.targets.copilot` frontmatter section (`AgentAPIProxyTargetConfig`), and wire them through `BuildAWFConfigJSON` into the `apiProxy.targets.copilot` section of the generated AWF config. A dedicated `extractCopilotTargetConfig` helper (mirroring `extractAPITargetAuthHeader`) reads these fields from the parsed frontmatter and merges them into an existing or newly created copilot target entry. `sessionId` is opt-in only and is never auto-derived from `GITHUB_RUN_ID`, because strict OpenAI-compatible upstreams (e.g., Azure OpenAI) reject the unknown `session_id` body field with HTTP 400. + +### Alternatives Considered + +#### Alternative 1: Expose fields as top-level engine config rather than under `sandbox.agent.targets.copilot` + +Placing `extraHeaders` and `extraBodyFields` directly on the `engine` or `sandbox.agent` frontmatter block would have been a shorter path. This was rejected because it would be inconsistent with the established convention: per-provider API proxy overrides already live under `sandbox.agent.targets.`, and `authHeader` already follows this pattern for OpenAI and Anthropic targets. Deviating would have fragmented the per-provider config surface. + +#### Alternative 2: Auto-derive `sessionId` from `GITHUB_RUN_ID` at compile time + +Automatically populating `sessionId` from the `GITHUB_RUN_ID` environment variable would reduce boilerplate for tracking purposes. This was rejected because strict OpenAI-compatible servers (including Azure OpenAI, a primary BYOK target) reject requests containing an unknown `session_id` body field with HTTP 400. Auto-derivation would silently break BYOK configurations that work today. Opt-in is the only safe default. + +### Consequences + +#### Positive +- Workflow authors targeting OpenRouter-style BYOK providers can configure required routing headers (`x-openrouter-title`, `http-referer`) and custom body fields declaratively in frontmatter, without post-compile JSON edits that would be overwritten. +- The implementation follows existing patterns (`extractAPITargetAuthHeader` → `extractCopilotTargetConfig`), keeping the codebase consistent and the approach auditable against prior art. + +#### Negative +- `ExtraHeaders`, `ExtraBodyFields`, and `SessionId` are added to the generic `AWFAPITargetConfig` struct, but are semantically valid only for the `copilot` provider target. Code comments call this out, but misapplication to other providers (e.g., `openai`, `anthropic`) would be silently ignored rather than rejected. +- `sessionId` requires explicit opt-in with the full expression (e.g., `${{ github.run_id }}`). Authors wanting automatic session correlation must add it manually; there is no convenience default. + +#### Neutral +- The sidecar env vars (`AWF_BYOK_EXTRA_HEADERS`, `AWF_BYOK_EXTRA_BODY_FIELDS`, `AWF_PROVIDER_SESSION_ID`) already existed in the AWF proxy sidecar; this PR is a pure frontmatter-to-AWF-config wiring change with no sidecar modifications. +- The spec drift table (`specs/awf-config-sources-spec.md`) now tracks the three new mappings, extending the existing pattern for `apiProxy.targets.*.authHeader`. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json index a0d35bace50..2165df7dc76 100644 --- a/pkg/cli/data/agentic_workflows_fallback_aw_files.json +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -33,6 +33,7 @@ "optimize-agentic-workflow.md", "patterns.md", "pr-reviewer.md", + "release-workflow.md", "report.md", "reuse.md", "safe-outputs-automation.md", diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 86f389daa73..e623558493f 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -2486,6 +2486,139 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AwfApiProxyTargets t.Error("unknown provider in sandbox.agent.targets should be rejected") } }) + + t.Run("copilot extraHeaders map is accepted", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "extraHeaders": map[string]any{ + "x-openrouter-title": "my-workflow", + "http-referer": "https://github.com/org/repo", + }, + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-extra-headers-test.md") + if err != nil { + t.Errorf("valid copilot extraHeaders should be accepted, got error: %v", err) + } + }) + + t.Run("copilot extraBodyFields map is accepted", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "extraBodyFields": map[string]any{ + "custom-field": "custom-value", + }, + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-extra-body-fields-test.md") + if err != nil { + t.Errorf("valid copilot extraBodyFields should be accepted, got error: %v", err) + } + }) + + t.Run("copilot sessionId string is accepted", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "sessionId": "${{ github.run_id }}", + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-session-id-test.md") + if err != nil { + t.Errorf("valid copilot sessionId should be accepted, got error: %v", err) + } + }) + + t.Run("copilot all three BYOK fields together are accepted", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "extraHeaders": map[string]any{ + "x-openrouter-title": "my-workflow", + }, + "extraBodyFields": map[string]any{ + "custom-field": "custom-value", + }, + "sessionId": "${{ github.run_id }}", + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-byok-all-fields-test.md") + if err != nil { + t.Errorf("all three copilot BYOK fields together should be accepted, got error: %v", err) + } + }) + + t.Run("copilot non-string extraHeaders value is rejected", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "extraHeaders": map[string]any{ + "x-count": 42, + }, + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-extra-headers-invalid-test.md") + if err == nil { + t.Error("non-string extraHeaders value should be rejected by schema validation") + } + }) + + t.Run("copilot unknown field in target is rejected", func(t *testing.T) { + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "sandbox": map[string]any{ + "agent": map[string]any{ + "targets": map[string]any{ + "copilot": map[string]any{ + "unknownField": "value", + }, + }, + }, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/awf-copilot-unknown-field-test.md") + if err == nil { + t.Error("unknown field in copilot target should be rejected by schema validation") + } + }) } // TestValidateMainWorkflowFrontmatter_OnPermissionsVulnerabilityAlerts validates that diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5ee805b2450..cf7950a4524 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3515,6 +3515,9 @@ }, "anthropic": { "$ref": "#/$defs/sandbox_agent_api_target" + }, + "copilot": { + "$ref": "#/$defs/sandbox_copilot_api_target" } }, "additionalProperties": false @@ -13802,6 +13805,35 @@ }, "additionalProperties": false }, + "sandbox_copilot_api_target": { + "type": "object", + "description": "AWF API proxy target configuration for the Copilot BYOK provider. Supports injecting additional headers, body fields, and an optional session ID on upstream requests.", + "properties": { + "authHeader": { + "type": "string", + "description": "Custom authentication header name to use when forwarding requests to the Copilot API. Example: \"api-key\" for Azure OpenAI gateways." + }, + "extraHeaders": { + "type": "object", + "description": "Additional non-sensitive HTTP headers to inject on Copilot BYOK upstream requests. Maps to AWF_BYOK_EXTRA_HEADERS. Example: { \"x-openrouter-title\": \"my-workflow\" }.", + "additionalProperties": { + "type": "string" + } + }, + "extraBodyFields": { + "type": "object", + "description": "Additional non-sensitive JSON body fields to inject on Copilot BYOK upstream requests. Maps to AWF_BYOK_EXTRA_BODY_FIELDS. Example: { \"custom-field\": \"custom-value\" }.", + "additionalProperties": { + "type": "string" + } + }, + "sessionId": { + "type": "string", + "description": "Optional session identifier injected as the x-session-id request header and session_id body field on Copilot BYOK upstream requests. Maps to AWF_PROVIDER_SESSION_ID. Only set this field when your upstream supports it — strict OpenAI-compatible upstreams (e.g. Azure OpenAI) reject the unknown session_id body field with HTTP 400. Example: \"${{ github.run_id }}\"." + } + }, + "additionalProperties": false + }, "numeric_or_numeric_string": { "description": "Accepts a JSON number, or a numeric string in integer, decimal, or scientific notation (for example: 123, 12.34, 1.23e-4, +5.67E+8). Rejects trailing/non-numeric formats (for example: 123abc, free, 1.2.3).", "anyOf": [ diff --git a/pkg/workflow/README.md b/pkg/workflow/README.md index 636dff2a857..7fdc2fd52a0 100644 --- a/pkg/workflow/README.md +++ b/pkg/workflow/README.md @@ -761,7 +761,7 @@ This appendix is generated from the current non-test Go source files in this pac | `artifacts.go` | `ArtifactDownloadConfig` | `type ArtifactDownloadConfig struct { ArtifactName string // Name of the artifact to download (e.g., "agent-output", "prompt") ArtifactFilename string // Filename inside the artifact directory (e.g., "agent_output.json", "prompt.txt") DownloadPath string // Path where artifact will be downloaded (e.g., "/tmp/gh-aw/safeoutputs/") SetupEnvStep bool // Whether to add environment variable setup step EnvVarName string // Environment variable name to set (e.g., "GH_AW_AGENT_OUTPUT") StepName string // Optional custom step name (defaults to "Download {artifact} artifact") IfCondition string // Optional conditional expression for the step (e.g., "needs.agent.outputs.has_patch == 'true'") StepID string // Optional step ID; when set, the env-setup step is gated on this step's success }` | ArtifactDownloadConfig holds configuration for building artifact download steps | | `auto_update_workflow.go` | `GenerateAutoUpdateWorkflowOptions` | `type GenerateAutoUpdateWorkflowOptions struct { // Context is used for action reference resolution in non-dev modes. // When nil, context.Background() is used. Context context.Context // WorkflowDir is the directory where the workflow file will be written. WorkflowDir string // Enabled indicates whether auto-updates are enabled in the repo config. Enabled bool // RepoSlug is the owner/repo slug used to deterministically scatter the // weekly cron schedule across different repositories. Pass an empty string // when the slug is not available; scattering will still succeed using only // the workflow identifier as seed. RepoSlug string // SetupActionRef is the resolved reference for the gh-aw actions/setup action. // For example: "./actions/setup" (dev mode) or "github/gh-aw/actions/setup@" (release mode). // When empty, "./actions/setup" is used as a fallback. SetupActionRef string // GitHubScriptPin is the pinned reference for actions/github-script. // When empty, getActionPin("actions/github-script") is used as a fallback. GitHubScriptPin string // ActionMode controls how CLI install steps and command prefixes are generated. // Defaults to ActionModeDev when empty. ActionMode ActionMode // Version is the gh-aw version used by generateInstallCLISteps in non-dev modes. Version string // ActionTag optionally overrides the setup-cli version tag in non-dev modes. ActionTag string // Resolver optionally resolves setup-cli action tags to SHA-pinned refs. Resolver SHAResolver }` | GenerateAutoUpdateWorkflowOptions configures an auto-update workflow generation run. | | `awf_config.go` | `AWFAPIProxyConfig` | `type AWFAPIProxyConfig struct { // Enabled enables the API proxy sidecar for LLM gateway credential isolation. // Maps to: --enable-api-proxy Enabled bool `json:"enabled"` // EnableTokenSteering enables budget-warning system message injection near ET budget exhaustion. EnableTokenSteering bool `json:"enableTokenSteering,omitempty"` // MaxRuns is the maximum number of LLM invocations allowed for a run. MaxRuns int `json:"maxRuns,omitempty"` // MaxTurnCacheMisses is the maximum number of consecutive cache misses allowed for a run. MaxTurnCacheMisses int `json:"maxCacheMisses,omitempty"` // MaxAICredits is the explicit per-run AI credits budget enforced by the API proxy. MaxAICredits int64 `json:"maxAiCredits,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"` // Targets holds per-provider API target overrides. // Supported keys: "openai", "anthropic", "copilot", "gemini" // The "gemini" target is also used for Antigravity engine routing. Targets map[string]*AWFAPITargetConfig `json:"targets,omitempty"` // Models contains model alias and fallback policy definitions. // Keys are alias names (empty string "" = default policy); values are ordered // lists of vendor/modelid patterns or other alias names to try in sequence. // AWF resolves aliases recursively; loops are not permitted. // Per the AWF config schema, this lives under apiProxy.models. Models map[string][]string `json:"models,omitempty"` // AllowedModels is the explicit allowlist policy for model names/patterns. AllowedModels []string `json:"allowedModels,omitempty"` // DisallowedModels is the explicit denylist policy for model names/patterns. DisallowedModels []string `json:"disallowedModels,omitempty"` }` | AWFAPIProxyConfig is the "apiProxy" section of the AWF config file. | -| `awf_config.go` | `AWFAPITargetConfig` | `type AWFAPITargetConfig struct { // Host is the hostname (and optional port) of the API endpoint. Host string `json:"host,omitempty"` // AuthHeader is the custom authentication header name sent with API requests. // When set, the raw API key is sent as ": " instead of the // provider default (e.g. "Authorization: ******" for OpenAI, or // "x-api-key: " for Anthropic). This supports gateways like Azure OpenAI // that require "api-key: " in place of the standard provider scheme. // Maps to: --openai-api-auth-header / --anthropic-api-auth-header AuthHeader string `json:"authHeader,omitempty"` }` | AWFAPITargetConfig is a single API proxy target entry. | +| `awf_config.go` | `AWFAPITargetConfig` | `type AWFAPITargetConfig struct { // Host is the hostname (and optional port) of the API endpoint. Host string `json:"host,omitempty"` // AuthHeader is the custom authentication header name sent with API requests. // When set, the raw API key is sent as ": " instead of the // provider default (e.g. "Authorization: ******" for OpenAI, or // "x-api-key: " for Anthropic). This supports gateways like Azure OpenAI // that require "api-key: " in place of the standard provider scheme. // Maps to: --openai-api-auth-header / --anthropic-api-auth-header AuthHeader string `json:"authHeader,omitempty"` // ExtraHeaders holds additional non-sensitive headers injected on Copilot BYOK upstream // requests. Only valid for the "copilot" provider target. // Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar. ExtraHeaders map[string]string `json:"extraHeaders,omitempty"` // ExtraBodyFields holds additional non-sensitive JSON body fields injected on Copilot BYOK // upstream requests. Only valid for the "copilot" provider target. // Maps to AWF_BYOK_EXTRA_BODY_FIELDS in the sidecar. ExtraBodyFields map[string]string `json:"extraBodyFields,omitempty"` // SessionId is an opt-in session identifier injected as the x-session-id request header // and session_id body field on Copilot BYOK upstream requests. Only valid for the // "copilot" provider target. // Maps to AWF_PROVIDER_SESSION_ID in the sidecar. SessionId string `json:"sessionId,omitempty"` }` | AWFAPITargetConfig is a single API proxy target entry. | | `awf_config.go` | `AWFChrootConfig` | `type AWFChrootConfig struct { // BinariesSourcePath is the runner-side directory to overlay at /usr/local/bin // inside chroot mode for split-filesystem ARC/DinD runners. BinariesSourcePath string `json:"binariesSourcePath,omitempty"` // Identity configures identity values applied after chroot pivot to override // HOME/USER/LOGNAME defaults inside chroot mode. Identity *AWFChrootIdentityConfig `json:"identity,omitempty"` }` | AWFChrootConfig is the "chroot" section of the AWF config file. | | `awf_config.go` | `AWFChrootIdentityConfig` | `type AWFChrootIdentityConfig struct { // User is the USER/LOGNAME string to export inside chroot mode. User string `json:"user,omitempty"` // UID is the UID hint used for chroot identity synthesis and user switching. // Must be >= 1 (root is not supported). UID int `json:"uid,omitempty"` // GID is the GID hint used for chroot identity synthesis and user switching. // Must be >= 1. GID int `json:"gid,omitempty"` // Home is the home directory path to export inside chroot mode. Home string `json:"home,omitempty"` }` | AWFChrootIdentityConfig is the "chroot. | | `awf_config.go` | `AWFConfigFile` | `type AWFConfigFile struct { // Schema is the JSON schema reference for IDE auto-complete support. Schema string `json:"$schema,omitempty"` // Runner contains runner topology metadata that AWF uses to activate // topology-specific behaviors (split-filesystem handling, network isolation, // tool cache redirection, sysroot image selection). Runner *AWFRunnerConfig `json:"runner,omitempty"` // 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"` // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` // Logging contains logging and diagnostics configuration. Logging *AWFLoggingConfig `json:"logging,omitempty"` // Chroot contains chroot execution overrides for split-filesystem ARC/DinD runners. // This field is not populated at compile time; it is injected at runtime when DinD topology is detected. Chroot *AWFChrootConfig `json:"chroot,omitempty"` }` | AWFConfigFile represents the AWF configuration file schema. | @@ -864,7 +864,7 @@ This appendix is generated from the current non-test Go source files in this pac | `safe_update_manifest.go` | `GHAWManifestAction` | `type GHAWManifestAction struct { Repo string `json:"repo"` SHA string `json:"sha"` Version string `json:"version,omitempty"` }` | GHAWManifestAction represents a single GitHub Action referenced in a compiled workflow. | | `safe_update_manifest.go` | `GHAWManifestResolutionFailure` | `type GHAWManifestResolutionFailure struct { Repo string `json:"repo"` Ref string `json:"ref"` ErrorType string `json:"error_type"` }` | GHAWManifestResolutionFailure represents an action-ref pinning failure captured during compilation. | | `samples_replay.go` | `SampleEntry` | `type SampleEntry struct { // Tool is the snake_case MCP tool name (e.g. "create_pull_request"). Tool string `json:"tool"` // Arguments are passed verbatim as the MCP `tools/call` arguments. // Sample sidecar fields (e.g. `patch`) have already been stripped. Arguments map[string]any `json:"arguments"` // Sidecars carries fields stripped from Arguments that need out-of-band // pre-staging by the driver (e.g. `patch` for create_pull_request). Sidecars map[string]any `json:"sidecars,omitempty"` }` | SampleEntry is the per-call payload consumed by apply_samples. | -| `sandbox.go` | `AgentAPIProxyTargetConfig` | `type AgentAPIProxyTargetConfig struct { // AuthHeader is the custom authentication header name sent with API requests. // When set, the raw API key is sent as ": " instead of the // provider default ("Authorization" for OpenAI, "x-api-key" for Anthropic). // Example: "api-key" for Azure OpenAI gateways. AuthHeader string `yaml:"authHeader,omitempty"` }` | AgentAPIProxyTargetConfig configures a single LLM provider's API proxy target. | +| `sandbox.go` | `AgentAPIProxyTargetConfig` | `type AgentAPIProxyTargetConfig struct { // AuthHeader is the custom authentication header name sent with API requests. // When set, the raw API key is sent as ": " instead of the // provider default ("Authorization" for OpenAI, "x-api-key" for Anthropic). // Example: "api-key" for Azure OpenAI gateways. AuthHeader string `yaml:"authHeader,omitempty"` // ExtraHeaders holds additional non-sensitive headers to include on Copilot BYOK // upstream requests. Applies only to the "copilot" provider target. // Maps to apiProxy.targets.copilot.extraHeaders in the AWF config (AWF_BYOK_EXTRA_HEADERS). ExtraHeaders map[string]string `yaml:"extraHeaders,omitempty"` // ExtraBodyFields holds additional non-sensitive JSON body fields to include on Copilot // BYOK upstream requests. Applies only to the "copilot" provider target. // Maps to apiProxy.targets.copilot.extraBodyFields in the AWF config (AWF_BYOK_EXTRA_BODY_FIELDS). ExtraBodyFields map[string]string `yaml:"extraBodyFields,omitempty"` // SessionId is an opt-in session identifier injected as the x-session-id request header // and session_id body field on Copilot BYOK upstream requests. Applies only to the // "copilot" provider target. // Maps to apiProxy.targets.copilot.sessionId in the AWF config (AWF_PROVIDER_SESSION_ID). SessionId string `yaml:"sessionId,omitempty"` }` | AgentAPIProxyTargetConfig configures a single LLM provider's API proxy target. | | `sandbox.go` | `AgentRuntime` | `type AgentRuntime string` | AgentRuntime represents the container runtime to use for the agent container. | | `script_registry.go` | `ScriptRegistry` | `type ScriptRegistry struct { mu sync.RWMutex scripts map[string]*scriptEntry }` | ScriptRegistry manages script metadata and custom action paths. | | `secret_extraction.go` | `SecretExpression` | `type SecretExpression struct { VarName string // The secret variable name (e.g., "DD_API_KEY") FullExpr string // The full expression (e.g., "${{ secrets.DD_API_KEY }}") }` | SecretExpression represents a parsed secret expression | diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 7328df86bd3..9efd20b06cf 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -294,6 +294,22 @@ type AWFAPITargetConfig struct { // that require "api-key: " in place of the standard provider scheme. // Maps to: --openai-api-auth-header / --anthropic-api-auth-header AuthHeader string `json:"authHeader,omitempty"` + + // ExtraHeaders holds additional non-sensitive headers injected on Copilot BYOK upstream + // requests. Only valid for the "copilot" provider target (copilotTarget in the AWF schema). + // Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar. + ExtraHeaders map[string]string `json:"extraHeaders,omitempty"` + + // ExtraBodyFields holds additional non-sensitive JSON body fields injected on Copilot BYOK + // upstream requests. Only valid for the "copilot" provider target. + // Maps to AWF_BYOK_EXTRA_BODY_FIELDS in the sidecar. + ExtraBodyFields map[string]string `json:"extraBodyFields,omitempty"` + + // SessionId is an opt-in session identifier injected as the x-session-id request header + // and session_id body field on Copilot BYOK upstream requests. Only valid for the + // "copilot" provider target. Must be set explicitly; never auto-derived from GITHUB_RUN_ID. + // Maps to AWF_PROVIDER_SESSION_ID in the sidecar. + SessionId string `json:"sessionId,omitempty"` } // AWFContainerConfig is the "container" section of the AWF config file. @@ -544,6 +560,33 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { targets["copilot"] = &AWFAPITargetConfig{Host: copilotTarget} awfConfigLog.Printf("API proxy: custom copilot target=%s", copilotTarget) } + + // Apply BYOK supplemental fields from sandbox.agent.targets.copilot frontmatter. + // extraHeaders, extraBodyFields, and sessionId are Copilot-specific and map to + // AWF_BYOK_EXTRA_HEADERS, AWF_BYOK_EXTRA_BODY_FIELDS, and AWF_PROVIDER_SESSION_ID. + if copilotFrontmatter := extractCopilotTargetConfig(config.WorkflowData); copilotFrontmatter != nil { + existing, ok := targets["copilot"] + if !ok { + existing = &AWFAPITargetConfig{} + targets["copilot"] = existing + } + if copilotFrontmatter.AuthHeader != "" { + existing.AuthHeader = copilotFrontmatter.AuthHeader + awfConfigLog.Printf("API proxy: copilot authHeader=%s", copilotFrontmatter.AuthHeader) + } + if len(copilotFrontmatter.ExtraHeaders) > 0 { + existing.ExtraHeaders = copilotFrontmatter.ExtraHeaders + awfConfigLog.Printf("API proxy: copilot extraHeaders configured (%d header(s))", len(copilotFrontmatter.ExtraHeaders)) + } + if len(copilotFrontmatter.ExtraBodyFields) > 0 { + existing.ExtraBodyFields = copilotFrontmatter.ExtraBodyFields + awfConfigLog.Printf("API proxy: copilot extraBodyFields configured (%d field(s))", len(copilotFrontmatter.ExtraBodyFields)) + } + if copilotFrontmatter.SessionId != "" { + existing.SessionId = copilotFrontmatter.SessionId + awfConfigLog.Printf("API proxy: copilot sessionId configured") + } + } if antigravityTarget := GetAntigravityAPITarget(config.WorkflowData, config.EngineName); antigravityTarget != "" { // Route the Antigravity-resolved API target through the "gemini" provider key // to match AWF's supported target providers. diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 7b9a8e23d33..4cb6c0d00ba 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -787,6 +787,206 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.NotContains(t, jsonStr, `"authHeader"`, "authHeader should be absent when not configured") }) + t.Run("copilot extraHeaders from frontmatter sandbox.agent.targets.copilot are included", 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{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + ExtraHeaders: map[string]string{ + "x-openrouter-title": "my-workflow", + "http-referer": "https://github.com/org/repo", + }, + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"copilot"`, "should include copilot target") + assert.Contains(t, jsonStr, `"extraHeaders"`, "should include extraHeaders in copilot target") + assert.Contains(t, jsonStr, `"x-openrouter-title"`, "should include x-openrouter-title header key") + assert.Contains(t, jsonStr, `"my-workflow"`, "should include header value") + }) + + t.Run("copilot extraBodyFields from frontmatter sandbox.agent.targets.copilot are included", 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{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + ExtraBodyFields: map[string]string{ + "custom-field": "custom-value", + }, + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"extraBodyFields"`, "should include extraBodyFields in copilot target") + assert.Contains(t, jsonStr, `"custom-field"`, "should include body field key") + assert.Contains(t, jsonStr, `"custom-value"`, "should include body field value") + }) + + t.Run("copilot sessionId from frontmatter sandbox.agent.targets.copilot is included", 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{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + SessionId: "${{ github.run_id }}", + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"sessionId"`, "should include sessionId in copilot target") + assert.Contains(t, jsonStr, `"${{ github.run_id }}"`, "should include sessionId value") + }) + + t.Run("copilot authHeader from frontmatter sandbox.agent.targets.copilot is included", 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{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + AuthHeader: "api-key", + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"copilot"`, "should include copilot target") + assert.Contains(t, jsonStr, `"authHeader":"api-key"`, "should include copilot authHeader in apiProxy targets") + assert.NotContains(t, jsonStr, `"host":""`, "should not emit empty host when only authHeader is set") + }) + + t.Run("copilot BYOK fields coexist with host from GetCopilotAPITarget", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "copilot", + APITarget: "copilot-gateway.internal", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + ExtraHeaders: map[string]string{ + "x-title": "my-workflow", + }, + SessionId: "run-123", + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, "copilot-gateway.internal", "should include host from api-target") + assert.Contains(t, jsonStr, `"extraHeaders"`, "should include extraHeaders alongside host") + assert.Contains(t, jsonStr, `"sessionId"`, "should include sessionId alongside host") + }) + + t.Run("copilot BYOK fields create entry even without host override", 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{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + ExtraHeaders: map[string]string{ + "x-title": "my-workflow", + }, + }, + }, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"copilot"`, "should include copilot target entry even without host") + assert.Contains(t, jsonStr, `"extraHeaders"`, "should include extraHeaders") + assert.NotContains(t, jsonStr, `"host":""`, "should not emit empty host") + }) + + t.Run("copilot BYOK fields are absent when 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}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"extraHeaders"`, "extraHeaders should be absent when not configured") + assert.NotContains(t, jsonStr, `"extraBodyFields"`, "extraBodyFields should be absent when not configured") + assert.NotContains(t, jsonStr, `"sessionId"`, "sessionId 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", @@ -1222,6 +1422,35 @@ func TestBuildAWFConfigJSON_SchemaCompliance(t *testing.T) { }(), }, }, + { + name: "config with copilot BYOK extra headers and sessionId", + config: AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Targets: map[string]*AgentAPIProxyTargetConfig{ + "copilot": { + ExtraHeaders: map[string]string{ + "x-openrouter-title": "my-workflow", + "http-referer": "https://github.com/org/repo", + }, + ExtraBodyFields: map[string]string{ + "custom-field": "custom-value", + }, + SessionId: "run-12345", + }, + }, + }, + }, + }, + }, + }, } for _, tc := range cases { diff --git a/pkg/workflow/copilot_byok_extra_fields_compilation_test.go b/pkg/workflow/copilot_byok_extra_fields_compilation_test.go new file mode 100644 index 00000000000..22dde13e971 --- /dev/null +++ b/pkg/workflow/copilot_byok_extra_fields_compilation_test.go @@ -0,0 +1,94 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/require" +) + +// TestCopilotBYOKExtraFieldsInCompiledWorkflow is an end-to-end compilation test +// verifying that sandbox.agent.targets.copilot.extraHeaders, extraBodyFields, and +// sessionId in frontmatter flow all the way through compilation into the AWF config +// JSON embedded in the generated .lock.yml file. +// +// This test covers the full path: frontmatter schema validation → WorkflowData parsing +// → BuildAWFConfigJSON → lock file printf script. +func TestCopilotBYOKExtraFieldsInCompiledWorkflow(t *testing.T) { + workflow := `--- +on: push +permissions: + contents: read +engine: + id: copilot + env: + COPILOT_PROVIDER_BASE_URL: "https://openrouter.ai/api/v1" + COPILOT_PROVIDER_API_KEY: "${{ secrets.OPENROUTER_API_KEY }}" +sandbox: + agent: + targets: + copilot: + extraHeaders: + x-openrouter-title: my-workflow + http-referer: https://github.com/org/repo + extraBodyFields: + custom-field: custom-value + sessionId: "${{ github.run_id }}" +strict: false +safe-outputs: + create-issue: +--- + +# BYOK workflow with extraHeaders, extraBodyFields, and sessionId +` + + tmpDir := testutil.TempDir(t, "copilot-byok-extra-fields") + testFile := filepath.Join(tmpDir, "byok-extra-fields.md") + require.NoError(t, os.WriteFile(testFile, []byte(workflow), 0644), "failed to write workflow file") + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(testFile), "failed to compile workflow") + + lockContent, err := os.ReadFile(stringutil.MarkdownToLockFile(testFile)) + require.NoError(t, err, "failed to read compiled lock file") + lockStr := string(lockContent) + + // extraHeaders should appear in the AWF config JSON (either escaped or unescaped, + // depending on whether shell-escaping was applied to the printf argument). + if !strings.Contains(lockStr, `"extraHeaders"`) && !strings.Contains(lockStr, `\"extraHeaders\"`) { + t.Error("expected extraHeaders to be present in compiled AWF config JSON") + } + if !strings.Contains(lockStr, `"x-openrouter-title"`) && !strings.Contains(lockStr, `\"x-openrouter-title\"`) { + t.Error("expected x-openrouter-title header key in compiled AWF config JSON") + } + if !strings.Contains(lockStr, `"my-workflow"`) && !strings.Contains(lockStr, `\"my-workflow\"`) { + t.Error("expected my-workflow header value in compiled AWF config JSON") + } + + // extraBodyFields should appear in the AWF config JSON. + if !strings.Contains(lockStr, `"extraBodyFields"`) && !strings.Contains(lockStr, `\"extraBodyFields\"`) { + t.Error("expected extraBodyFields to be present in compiled AWF config JSON") + } + if !strings.Contains(lockStr, `"custom-field"`) && !strings.Contains(lockStr, `\"custom-field\"`) { + t.Error("expected custom-field body field key in compiled AWF config JSON") + } + if !strings.Contains(lockStr, `"custom-value"`) && !strings.Contains(lockStr, `\"custom-value\"`) { + t.Error("expected custom-value body field value in compiled AWF config JSON") + } + + // sessionId should appear in the AWF config JSON with the full expression preserved. + if !strings.Contains(lockStr, `"sessionId"`) && !strings.Contains(lockStr, `\"sessionId\"`) { + t.Error("expected sessionId to be present in compiled AWF config JSON") + } + // The ${{ github.run_id }} GitHub Actions expression must be preserved verbatim so + // it is evaluated at runtime. Check for the full expression syntax. + if !strings.Contains(lockStr, `${{ github.run_id }}`) { + t.Error("expected full '${{ github.run_id }}' expression in compiled AWF config JSON sessionId") + } +} diff --git a/pkg/workflow/engine_api_targets.go b/pkg/workflow/engine_api_targets.go index 149f7ecb98f..f9f09fba6a6 100644 --- a/pkg/workflow/engine_api_targets.go +++ b/pkg/workflow/engine_api_targets.go @@ -133,6 +133,19 @@ func extractAPITargetAuthHeader(workflowData *WorkflowData, provider string) str return target.AuthHeader } +// extractCopilotTargetConfig returns the AgentAPIProxyTargetConfig for the "copilot" provider +// from the sandbox.agent.targets frontmatter section. Returns nil if not configured. +func extractCopilotTargetConfig(workflowData *WorkflowData) *AgentAPIProxyTargetConfig { + if workflowData == nil || workflowData.SandboxConfig == nil || workflowData.SandboxConfig.Agent == nil { + return nil + } + targets := workflowData.SandboxConfig.Agent.Targets + if targets == nil { + return nil + } + return targets["copilot"] +} + // GetCopilotAPITarget returns the effective Copilot API target hostname, checking in order: // 1. engine.api-target (explicit, takes precedence) // 2. GITHUB_COPILOT_BASE_URL in engine.env (implicit, derived from the configured Copilot base URL) diff --git a/pkg/workflow/engine_api_targets_file_organization_test.go b/pkg/workflow/engine_api_targets_file_organization_test.go index b66daf79bc8..ad09ff840e9 100644 --- a/pkg/workflow/engine_api_targets_file_organization_test.go +++ b/pkg/workflow/engine_api_targets_file_organization_test.go @@ -18,6 +18,7 @@ func TestAWFHelpersDoesNotContainEngineAPITargetHelpers(t *testing.T) { "func extractAPITargetHost(", "func extractAPIBasePath(", "func extractAPITargetAuthHeader(", + "func extractCopilotTargetConfig(", "func GetCopilotAPITarget(", "func extractLiteralEngineEnvHost(", "func GetCopilotAllowlistTargets(", diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index b5aa5b922d6..446c29afba4 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -301,6 +301,44 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract targets (per-provider API proxy target overrides, e.g. authHeader, extraHeaders) + if targetsVal, hasTargets := agentObj["targets"]; hasTargets { + if targetsObj, ok := targetsVal.(map[string]any); ok { + agentConfig.Targets = make(map[string]*AgentAPIProxyTargetConfig) + for provider, targetAny := range targetsObj { + targetObj, ok := targetAny.(map[string]any) + if !ok { + continue + } + targetConfig := &AgentAPIProxyTargetConfig{} + if authHeader, ok := targetObj["authHeader"].(string); ok { + targetConfig.AuthHeader = authHeader + } + if extraHeaders, ok := targetObj["extraHeaders"].(map[string]any); ok { + targetConfig.ExtraHeaders = make(map[string]string) + for k, v := range extraHeaders { + if s, ok := v.(string); ok { + targetConfig.ExtraHeaders[k] = s + } + } + } + if extraBodyFields, ok := targetObj["extraBodyFields"].(map[string]any); ok { + targetConfig.ExtraBodyFields = make(map[string]string) + for k, v := range extraBodyFields { + if s, ok := v.(string); ok { + targetConfig.ExtraBodyFields[k] = s + } + } + } + if sessionId, ok := targetObj["sessionId"].(string); ok { + targetConfig.SessionId = sessionId + } + agentConfig.Targets[provider] = targetConfig + } + frontmatterExtractionSecurityLog.Printf("Extracted sandbox.agent.targets: %d provider(s)", len(agentConfig.Targets)) + } + } + return agentConfig } diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index e49de49e4e9..b1c282f07b2 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -103,6 +103,31 @@ type AgentAPIProxyTargetConfig struct { // provider default ("Authorization" for OpenAI, "x-api-key" for Anthropic). // Example: "api-key" for Azure OpenAI gateways. AuthHeader string `yaml:"authHeader,omitempty"` + + // ExtraHeaders holds additional non-sensitive headers to include on Copilot BYOK + // upstream requests. Applies only to the "copilot" provider target. + // Maps to apiProxy.targets.copilot.extraHeaders in the AWF config (AWF_BYOK_EXTRA_HEADERS). + // Example: + // sandbox: + // agent: + // targets: + // copilot: + // extraHeaders: + // x-openrouter-title: my-workflow + // http-referer: https://github.com/org/repo + ExtraHeaders map[string]string `yaml:"extraHeaders,omitempty"` + + // ExtraBodyFields holds additional non-sensitive JSON body fields to include on Copilot + // BYOK upstream requests. Applies only to the "copilot" provider target. + // Maps to apiProxy.targets.copilot.extraBodyFields in the AWF config (AWF_BYOK_EXTRA_BODY_FIELDS). + ExtraBodyFields map[string]string `yaml:"extraBodyFields,omitempty"` + + // SessionId is an opt-in session identifier injected as the x-session-id request header + // and session_id body field on Copilot BYOK upstream requests. Applies only to the + // "copilot" provider target. Strict OpenAI-compatible servers (e.g. Azure OpenAI) reject + // the unknown body field with HTTP 400, so this value must be set explicitly. + // Maps to apiProxy.targets.copilot.sessionId in the AWF config (AWF_PROVIDER_SESSION_ID). + SessionId string `yaml:"sessionId,omitempty"` } // SandboxRuntimeConfig represents the Anthropic Sandbox Runtime configuration diff --git a/specs/awf-config-sources-spec.md b/specs/awf-config-sources-spec.md index ba2f79f4a82..6690e4e256e 100644 --- a/specs/awf-config-sources-spec.md +++ b/specs/awf-config-sources-spec.md @@ -63,6 +63,9 @@ The following fields previously existed in schema but were missed in spec CLI ma | `apiProxy.auth.*` | config-only (maps to `AWF_AUTH_*` env vars) | | `apiProxy.targets.openai.authHeader` | `--openai-api-auth-header` (frontmatter: `sandbox.agent.targets.openai.authHeader`) | | `apiProxy.targets.anthropic.authHeader` | `--anthropic-api-auth-header` (frontmatter: `sandbox.agent.targets.anthropic.authHeader`) | +| `apiProxy.targets.copilot.extraHeaders` | config-only (frontmatter: `sandbox.agent.targets.copilot.extraHeaders`; maps to `AWF_BYOK_EXTRA_HEADERS`) | +| `apiProxy.targets.copilot.extraBodyFields` | config-only (frontmatter: `sandbox.agent.targets.copilot.extraBodyFields`; maps to `AWF_BYOK_EXTRA_BODY_FIELDS`) | +| `apiProxy.targets.copilot.sessionId` | config-only (frontmatter: `sandbox.agent.targets.copilot.sessionId`; maps to `AWF_PROVIDER_SESSION_ID`) | | `container.dockerHostPathPrefix` | `--docker-host-path-prefix` | Agents SHOULD treat this class of mismatch as a regression signal and open a corrective PR when detected.