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
5 changes: 5 additions & 0 deletions containers/api-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Required (at least one):

Optional:
- `COPILOT_API_TARGET` - Target hostname for GitHub Copilot API requests (default: `api.githubcopilot.com`). Useful for GHES deployments.
- `AWF_BYOK_EXTRA_HEADERS` - JSON object of additional headers to inject into upstream requests when the Copilot BYOK API key (`COPILOT_PROVIDER_API_KEY`) is in use. Useful for provider-native observability (e.g. OpenRouter session grouping, Helicone user tracking):
```
AWF_BYOK_EXTRA_HEADERS='{"x-session-id":"my-session","HTTP-Referer":"https://example.com"}'
```
Auth-critical header names (`authorization`, `x-api-key`, etc.) are rejected. Headers are only sent when the BYOK API key is used; standard GitHub OAuth (`COPILOT_GITHUB_TOKEN`) requests are unaffected.

Set by AWF:
- `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128)
Expand Down
81 changes: 81 additions & 0 deletions containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN
* regardless of which auth mode is active, because the /models endpoint only
* accepts OAuth tokens, not API keys.
*
* BYOK extra headers: AWF_BYOK_EXTRA_HEADERS (JSON object) injects supplemental
* headers (e.g. x-session-id, HTTP-Referer) into upstream requests when the
* BYOK API key is in use. Auth-critical header names are rejected at parse time.
*/

const {
Expand All @@ -24,6 +28,77 @@ const {
const { sanitizeNullToolCallTypes } = require('../body-transform');
const { URL } = require('url');

/**
* Header names that must never be overridden by caller-supplied extra headers.
* These are the auth/proxy headers stripped or injected by the proxy itself.
*/
const PROTECTED_HEADER_NAMES = new Set([
'authorization',
'x-api-key',
'x-goog-api-key',
'proxy-authorization',
]);

/**
* Parse the AWF_BYOK_EXTRA_HEADERS environment variable into a plain header map.
*
* The value must be a JSON object whose keys are valid HTTP header names and
* whose values are strings. Invalid entries are skipped with a console warning;
* the function always returns a (possibly empty) object rather than throwing.
*
* Auth-critical header names (authorization, x-api-key, etc.) are rejected to
* prevent accidental credential injection via this configuration path.
*
* @param {string|undefined} raw - Raw value of AWF_BYOK_EXTRA_HEADERS
* @returns {Record<string, string>} Validated header map (may be empty)
*/
function parseByokExtraHeaders(raw) {
if (!raw || !raw.trim()) return {};

let parsed;
try {
parsed = JSON.parse(raw.trim());
} catch {
console.warn('AWF_BYOK_EXTRA_HEADERS: invalid JSON; ignoring extra headers');
return {};
}

if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
console.warn('AWF_BYOK_EXTRA_HEADERS: expected a JSON object; ignoring extra headers');
return {};
}

const result = {};
const http = require('http');
for (const [name, value] of Object.entries(parsed)) {
const lowerName = name.toLowerCase();

// Prevent prototype pollution / special keys in header maps.
if (lowerName === '__proto__' || lowerName === 'constructor' || lowerName === 'prototype') {
console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is not an allowed header name; skipping`);
continue;
}

if (PROTECTED_HEADER_NAMES.has(lowerName)) {
console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is an auth-critical header and cannot be overridden; skipping`);
continue;
}
try {
http.validateHeaderName(name);
} catch {
console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is not a valid HTTP header name; skipping`);
continue;
}
if (typeof value !== 'string') {
console.warn(`AWF_BYOK_EXTRA_HEADERS: value for "${name}" must be a string; skipping`);
continue;
}
result[name] = value;
}
Comment thread
lpcox marked this conversation as resolved.

return result;
}

// AWF injects this sentinel value into the *agent* environment for credential isolation.
// The ghu_ prefix is intentional: it matches the GitHub token shape that Copilot CLI
// auth pre-checks expect, but the 36 repeated 'a' characters make it unambiguous as
Expand Down Expand Up @@ -245,6 +320,10 @@ function createCopilotAdapter(env, deps = {}) {
const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const rawTarget = deriveCopilotApiTarget(env);
const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH);
// Extra headers to inject on all requests that use the BYOK API key.
// Only populated when AWF_BYOK_EXTRA_HEADERS is set; ignored for standard
// GitHub OAuth (COPILOT_GITHUB_TOKEN-only) requests.
const byokExtraHeaders = parseByokExtraHeaders(env.AWF_BYOK_EXTRA_HEADERS);

const bodyTransform = composeBodyTransforms(
deps.bodyTransform || null,
Expand Down Expand Up @@ -378,6 +457,7 @@ function createCopilotAdapter(env, deps = {}) {
}

return {
...(apiKey ? byokExtraHeaders : {}),
'Authorization': `Bearer ${authToken}`,
'Copilot-Integration-Id': integrationId,
};
Comment thread
lpcox marked this conversation as resolved.
Expand Down Expand Up @@ -425,5 +505,6 @@ module.exports = {
deriveGitHubApiBasePath,
isGithubCopilotCatalogTarget,
COPILOT_PLACEHOLDER_TOKEN,
parseByokExtraHeaders,
},
};
171 changes: 171 additions & 0 deletions containers/api-proxy/server.auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
resolveApiKey,
stripBearerPrefix,
COPILOT_PLACEHOLDER_TOKEN,
parseByokExtraHeaders,
},
createCopilotAdapter,
} = require('./providers/copilot');
Expand Down Expand Up @@ -338,6 +339,176 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => {
});
});

// ── parseByokExtraHeaders ─────────────────────────────────────────────────────

describe('parseByokExtraHeaders', () => {
it('returns empty object for undefined input', () => {
expect(parseByokExtraHeaders(undefined)).toEqual({});
});

it('returns empty object for empty string', () => {
expect(parseByokExtraHeaders('')).toEqual({});
});

it('returns empty object for whitespace-only string', () => {
expect(parseByokExtraHeaders(' ')).toEqual({});
});

it('parses a valid JSON object of string headers', () => {
const result = parseByokExtraHeaders('{"x-session-id":"sess-123","HTTP-Referer":"https://example.com"}');
expect(result).toEqual({
'x-session-id': 'sess-123',
'HTTP-Referer': 'https://example.com',
});
});

it('returns empty object and warns for invalid JSON', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{not-valid-json}');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
warnSpy.mockRestore();
});

it('returns empty object and warns when value is a JSON array (not object)', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('["x-session-id","value"]');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object'));
warnSpy.mockRestore();
});

it('returns empty object and warns when value is a JSON string (not object)', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('"just-a-string"');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object'));
warnSpy.mockRestore();
});

it('skips auth-critical header "authorization" with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"authorization":"******","x-session-id":"sess-1"}');
expect(result).not.toHaveProperty('authorization');
expect(result['x-session-id']).toBe('sess-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips auth-critical header "Authorization" (case-insensitive) with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"Authorization":"******"}');
expect(result).not.toHaveProperty('Authorization');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips auth-critical header "x-api-key" with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"x-api-key":"leaked-key"}');
expect(result).not.toHaveProperty('x-api-key');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips invalid HTTP header names with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"invalid header name":"value","x-valid":"ok"}');
expect(result).not.toHaveProperty('invalid header name');
expect(result['x-valid']).toBe('ok');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not a valid HTTP header name'));
warnSpy.mockRestore();
});

it('skips entries with non-string values with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"x-count":42,"x-session-id":"sess-1"}');
expect(result).not.toHaveProperty('x-count');
expect(result['x-session-id']).toBe('sess-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('must be a string'));
warnSpy.mockRestore();
});
});

// ── createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection ──────────────────

describe('createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection', () => {
const fakeReq = { url: '/v1/chat/completions', method: 'POST', headers: {} };
const fakeModelsReq = { url: '/models', method: 'GET', headers: {} };

it('injects extra BYOK headers on inference request when BYOK API key is set', () => {
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42","HTTP-Referer":"https://example.com"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['x-session-id']).toBe('sess-42');
expect(headers['HTTP-Referer']).toBe('https://example.com');
});

it('does not override Authorization or Copilot-Integration-Id with extra headers', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"Authorization":"malicious","Copilot-Integration-Id":"evil","x-session-id":"sess-1"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123');
expect(headers['Copilot-Integration-Id']).toBe('copilot-developer-cli');
expect(headers['x-session-id']).toBe('sess-1');
warnSpy.mockRestore();
});
Comment thread
lpcox marked this conversation as resolved.

it('does NOT inject extra headers when only GitHub OAuth token is set (no BYOK key)', () => {
const adapter = createCopilotAdapter({
COPILOT_GITHUB_TOKEN: 'gho_oauth_token',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['x-session-id']).toBeUndefined();
});

it('does NOT inject extra headers on /models GET when GitHub OAuth token is available', () => {
const adapter = createCopilotAdapter({
COPILOT_GITHUB_TOKEN: 'gho_oauth_token',
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
// /models GET with GitHub token goes to GitHub Copilot — extra headers must not be sent there
const headers = adapter.getAuthHeaders(fakeModelsReq);
expect(headers['Authorization']).toBe('Bearer gho_oauth_token');
expect(headers['x-session-id']).toBeUndefined();
});

it('injects extra BYOK headers on /models GET when only BYOK API key is set (no GitHub token)', () => {
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
// Without a GitHub token, /models GET uses the BYOK key and goes to the BYOK provider
const headers = adapter.getAuthHeaders(fakeModelsReq);
expect(headers['x-session-id']).toBe('sess-42');
});

it('does not inject extra headers when AWF_BYOK_EXTRA_HEADERS is not set', () => {
const adapter = createCopilotAdapter({ COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123' });
const headers = adapter.getAuthHeaders(fakeReq);
expect(Object.keys(headers)).toEqual(['Authorization', 'Copilot-Integration-Id']);
});

it('ignores invalid AWF_BYOK_EXTRA_HEADERS JSON and still authenticates normally', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{bad-json}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
warnSpy.mockRestore();
});
});

describe('createAnthropicAdapter — OIDC getAuthHeaders', () => {
const fakeReq = { url: '/v1/messages', method: 'POST', headers: {} };

Expand Down
1 change: 1 addition & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ the corresponding CLI flag.
- `apiProxy.auth.anthropicTokenUrl` → *(config-only; maps to `AWF_AUTH_ANTHROPIC_TOKEN_URL`)*
- `apiProxy.targets.<provider>.host` → `--<provider>-api-target` *(except `antigravity.host`, which maps to the Gemini flag below)*
- `apiProxy.targets.antigravity.host` → `--gemini-api-target`
- `apiProxy.targets.copilot.extraHeaders` → *(config-only; non-sensitive supplemental BYOK headers, maps to `AWF_BYOK_EXTRA_HEADERS`)*
- `apiProxy.targets.openai.basePath` → `--openai-api-base-path`
- `apiProxy.targets.openai.authHeader` → `--openai-api-auth-header`
- `apiProxy.targets.anthropic.basePath` → `--anthropic-api-base-path`
Expand Down
22 changes: 20 additions & 2 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@
"description": "Anthropic API target override."
},
"copilot": {
"$ref": "#/$defs/providerHostOnlyTarget",
"description": "GitHub Copilot API target override (basePath not supported)."
"$ref": "#/$defs/copilotTarget",
"description": "GitHub Copilot API target override and BYOK supplemental headers (basePath not supported)."
},
"gemini": {
"$ref": "#/$defs/providerTarget",
Expand Down Expand Up @@ -580,6 +580,24 @@
"description": "Override the provider API host."
}
}
},
"copilotTarget": {
"type": "object",
"description": "Copilot target override (host only) plus non-sensitive BYOK supplemental headers.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the Copilot API host."
},
"extraHeaders": {
"type": "object",
"description": "Additional non-sensitive headers to include on Copilot BYOK upstream requests. Values must be strings. Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar.",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
22 changes: 20 additions & 2 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@
"description": "Anthropic API target override."
},
"copilot": {
"$ref": "#/$defs/providerHostOnlyTarget",
"description": "GitHub Copilot API target override (basePath not supported)."
"$ref": "#/$defs/copilotTarget",
"description": "GitHub Copilot API target override and BYOK supplemental headers (basePath not supported)."
},
"gemini": {
"$ref": "#/$defs/providerTarget",
Expand Down Expand Up @@ -580,6 +580,24 @@
"description": "Override the provider API host."
}
}
},
"copilotTarget": {
"type": "object",
"description": "Copilot target override (host only) plus non-sensitive BYOK supplemental headers.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the Copilot API host."
},
"extraHeaders": {
"type": "object",
"description": "Additional non-sensitive headers to include on Copilot BYOK upstream requests. Values must be strings. Maps to AWF_BYOK_EXTRA_HEADERS in the sidecar.",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
Loading
Loading