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
43 changes: 43 additions & 0 deletions docs/adr/48096-expose-byok-extra-fields-in-copilot-frontmatter.md
Original file line number Diff line number Diff line change
@@ -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.<provider>`, 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.*
1 change: 1 addition & 0 deletions pkg/cli/data/agentic_workflows_fallback_aw_files.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"optimize-agentic-workflow.md",
"patterns.md",
"pr-reviewer.md",
"release-workflow.md",
"report.md",
"reuse.md",
"safe-outputs-automation.md",
Expand Down
133 changes: 133 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3515,6 +3515,9 @@
},
"anthropic": {
"$ref": "#/$defs/sandbox_agent_api_target"
},
"copilot": {
"$ref": "#/$defs/sandbox_copilot_api_target"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -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": [
Expand Down
Loading
Loading