From 68b8c1db115bf0e77b0336acfa809c106a661c20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:58:29 +0000 Subject: [PATCH 1/4] Initial plan From 51ec5f1d519312498ff8061d7c00b4b0446ce7c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:14:24 +0000 Subject: [PATCH 2/4] fix: route Copilot /models through GitHub REST API in api-proxy Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/f2e14a98-ef78-407d-9faf-79ea4a31623a --- containers/api-proxy/server.js | 44 +++++++++++++++- containers/api-proxy/server.test.js | 82 ++++++++++++++++++++++++++++- src/docker-manager.ts | 3 ++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 081b4ddd..7b4db35e 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -207,6 +207,34 @@ function deriveCopilotApiTarget() { } const COPILOT_API_TARGET = deriveCopilotApiTarget(); +// GitHub REST API target host for endpoints that live on the REST API, not the Copilot API. +// Used for model validation calls (GET /models) made by Copilot CLI 1.0.21+ when COPILOT_MODEL +// is set. The /models endpoint is part of the GitHub REST API, not the Copilot inference API. +// Priority: GITHUB_API_URL env var (hostname extracted) > auto-derive from GITHUB_SERVER_URL > default +function deriveGitHubApiTarget() { + // Explicit GITHUB_API_URL takes priority — this is the canonical source for enterprise deployments + if (process.env.GITHUB_API_URL) { + const target = normalizeApiTarget(process.env.GITHUB_API_URL); + if (target) return target; + } + // Auto-derive from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com) + const serverUrl = process.env.GITHUB_SERVER_URL; + if (serverUrl) { + try { + const hostname = new URL(serverUrl).hostname; + if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) { + // GHEC: GitHub REST API lives at api..ghe.com + const subdomain = hostname.slice(0, -8); // Remove '.ghe.com' + return `api.${subdomain}.ghe.com`; + } + } catch { + // Invalid URL — fall through to default + } + } + return 'api.github.com'; +} +const GITHUB_API_TARGET = deriveGitHubApiTarget(); + // Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose) const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; @@ -218,6 +246,7 @@ logRequest('info', 'startup', { anthropic: ANTHROPIC_API_TARGET, gemini: GEMINI_API_TARGET, copilot: COPILOT_API_TARGET, + github: GITHUB_API_TARGET, }, api_base_paths: { openai: OPENAI_API_BASE_PATH || '(none)', @@ -888,6 +917,19 @@ if (require.main === module) { const contentLength = parseInt(req.headers['content-length'], 10) || 0; if (checkRateLimit(req, res, 'copilot', contentLength)) return; + // Copilot CLI 1.0.21+ calls GET /models at startup when COPILOT_MODEL is set. + // This endpoint is part of the GitHub REST API (GITHUB_API_TARGET), not the Copilot + // inference API (COPILOT_API_TARGET). Route it to the correct target using the raw + // GitHub token so the validation succeeds with a fine-grained PAT or OAuth token. + const reqPathname = new URL(req.url, 'http://localhost').pathname; + const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/'); + if (isModelsPath && COPILOT_GITHUB_TOKEN) { + proxyRequest(req, res, GITHUB_API_TARGET, { + 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, + }, 'copilot-models'); + return; + } + proxyRequest(req, res, COPILOT_API_TARGET, { 'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`, }, 'copilot'); @@ -1011,4 +1053,4 @@ if (require.main === module) { } // Export for testing -module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken }; +module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 56ce7f02..ea5ee5a5 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -5,7 +5,7 @@ const http = require('http'); const tls = require('tls'); const { EventEmitter } = require('events'); -const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -165,6 +165,86 @@ describe('deriveCopilotApiTarget', () => { }); }); +describe('deriveGitHubApiTarget', () => { + let originalEnv; + + beforeEach(() => { + originalEnv = { + GITHUB_API_URL: process.env.GITHUB_API_URL, + GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL, + }; + delete process.env.GITHUB_API_URL; + delete process.env.GITHUB_SERVER_URL; + }); + + afterEach(() => { + if (originalEnv.GITHUB_API_URL !== undefined) { + process.env.GITHUB_API_URL = originalEnv.GITHUB_API_URL; + } else { + delete process.env.GITHUB_API_URL; + } + if (originalEnv.GITHUB_SERVER_URL !== undefined) { + process.env.GITHUB_SERVER_URL = originalEnv.GITHUB_SERVER_URL; + } else { + delete process.env.GITHUB_SERVER_URL; + } + }); + + describe('GITHUB_API_URL env var (highest priority)', () => { + it('should return hostname from GITHUB_API_URL full URL', () => { + process.env.GITHUB_API_URL = 'https://api.github.com'; + expect(deriveGitHubApiTarget()).toBe('api.github.com'); + }); + + it('should return hostname from GITHUB_API_URL for GHES', () => { + process.env.GITHUB_API_URL = 'https://github.internal/api/v3'; + expect(deriveGitHubApiTarget()).toBe('github.internal'); + }); + + it('should prefer GITHUB_API_URL over GITHUB_SERVER_URL', () => { + process.env.GITHUB_API_URL = 'https://api.mycompany.ghe.com'; + process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com'; + expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com'); + }); + }); + + describe('GHEC (*.ghe.com)', () => { + it('should return api..ghe.com for GHEC tenant', () => { + process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com'; + expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com'); + }); + + it('should handle multiple-level subdomains', () => { + process.env.GITHUB_SERVER_URL = 'https://sub.example.ghe.com'; + expect(deriveGitHubApiTarget()).toBe('api.sub.example.ghe.com'); + }); + }); + + describe('Default behavior', () => { + it('should return api.github.com when no env vars are set', () => { + expect(deriveGitHubApiTarget()).toBe('api.github.com'); + }); + + it('should return api.github.com for github.com GITHUB_SERVER_URL', () => { + process.env.GITHUB_SERVER_URL = 'https://github.com'; + expect(deriveGitHubApiTarget()).toBe('api.github.com'); + }); + + it('should return api.github.com for GHES without GITHUB_API_URL', () => { + // GHES without an explicit GITHUB_API_URL falls back to api.github.com. + // This is a known limitation: GHES deployments should set GITHUB_API_URL explicitly + // so deriveGitHubApiTarget() resolves to the correct enterprise API hostname. + process.env.GITHUB_SERVER_URL = 'https://github.internal'; + expect(deriveGitHubApiTarget()).toBe('api.github.com'); + }); + + it('should return api.github.com when GITHUB_SERVER_URL is invalid', () => { + process.env.GITHUB_SERVER_URL = 'not-a-valid-url'; + expect(deriveGitHubApiTarget()).toBe('api.github.com'); + }); + }); +}); + describe('normalizeBasePath', () => { it('should return empty string for undefined', () => { expect(normalizeBasePath(undefined)).toBe(''); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 94edf330..b03418d3 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1535,6 +1535,9 @@ export function generateDockerCompose( ...(config.geminiApiBasePath && { GEMINI_API_BASE_PATH: config.geminiApiBasePath }), // Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), + // Forward GITHUB_API_URL so api-proxy can route /models to the correct GitHub REST API + // target on GHES/GHEC (e.g. api.mycompany.ghe.com instead of api.github.com) + ...(process.env.GITHUB_API_URL && { GITHUB_API_URL: process.env.GITHUB_API_URL }), // Route through Squid to respect domain whitelisting HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, From c55647bd515b39ea8413d00d3b4110b341e3c7b7 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 12 Apr 2026 12:45:10 -0700 Subject: [PATCH 3/4] fix: address review feedback for /models routing - Extract GHES base path from GITHUB_API_URL so /models requests include the /api/v3 prefix on enterprise deployments - Wrap URL parsing in try/catch to prevent crashes on malformed request targets (returns 400 instead) - Use consistent 'copilot' provider label for rate limiting on /models requests (was 'copilot-models') - Gate /models dispatch to GET method only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/api-proxy/server.js | 34 ++++++++++++++++++++--- containers/api-proxy/server.test.js | 43 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 7b4db35e..21465195 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -233,7 +233,26 @@ function deriveGitHubApiTarget() { } return 'api.github.com'; } + +/** + * Extract the base path from GITHUB_API_URL for GHES deployments + * (e.g. https://ghes.example.com/api/v3 → '/api/v3'). + * Returns '' for github.com or when no path component is present. + */ +function deriveGitHubApiBasePath() { + const raw = process.env.GITHUB_API_URL; + if (!raw) return ''; + try { + const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`); + const p = parsed.pathname.replace(/\/+$/, ''); + return p === '/' ? '' : p; + } catch { + return ''; + } +} + const GITHUB_API_TARGET = deriveGitHubApiTarget(); +const GITHUB_API_BASE_PATH = deriveGitHubApiBasePath(); // Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose) const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; @@ -921,12 +940,19 @@ if (require.main === module) { // This endpoint is part of the GitHub REST API (GITHUB_API_TARGET), not the Copilot // inference API (COPILOT_API_TARGET). Route it to the correct target using the raw // GitHub token so the validation succeeds with a fine-grained PAT or OAuth token. - const reqPathname = new URL(req.url, 'http://localhost').pathname; + let reqPathname; + try { + reqPathname = new URL(req.url, 'http://localhost').pathname; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request URL' })); + return; + } const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/'); - if (isModelsPath && COPILOT_GITHUB_TOKEN) { + if (isModelsPath && req.method === 'GET' && COPILOT_GITHUB_TOKEN) { proxyRequest(req, res, GITHUB_API_TARGET, { 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, - }, 'copilot-models'); + }, 'copilot', GITHUB_API_BASE_PATH); return; } @@ -1053,4 +1079,4 @@ if (require.main === module) { } // Export for testing -module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken }; +module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index ea5ee5a5..9e7ea6c9 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -5,7 +5,7 @@ const http = require('http'); const tls = require('tls'); const { EventEmitter } = require('events'); -const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -245,6 +245,47 @@ describe('deriveGitHubApiTarget', () => { }); }); +describe('deriveGitHubApiBasePath', () => { + const savedEnv = {}; + + beforeEach(() => { + savedEnv.GITHUB_API_URL = process.env.GITHUB_API_URL; + delete process.env.GITHUB_API_URL; + }); + + afterEach(() => { + if (savedEnv.GITHUB_API_URL !== undefined) { + process.env.GITHUB_API_URL = savedEnv.GITHUB_API_URL; + } else { + delete process.env.GITHUB_API_URL; + } + }); + + it('should return empty string when GITHUB_API_URL is not set', () => { + expect(deriveGitHubApiBasePath()).toBe(''); + }); + + it('should extract /api/v3 from GHES-style GITHUB_API_URL', () => { + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3'; + expect(deriveGitHubApiBasePath()).toBe('/api/v3'); + }); + + it('should return empty string for github.com API URL (no path)', () => { + process.env.GITHUB_API_URL = 'https://api.github.com'; + expect(deriveGitHubApiBasePath()).toBe(''); + }); + + it('should strip trailing slashes', () => { + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3/'; + expect(deriveGitHubApiBasePath()).toBe('/api/v3'); + }); + + it('should return empty string for invalid URL', () => { + process.env.GITHUB_API_URL = '://invalid'; + expect(deriveGitHubApiBasePath()).toBe(''); + }); +}); + describe('normalizeBasePath', () => { it('should return empty string for undefined', () => { expect(normalizeBasePath(undefined)).toBe(''); From 6ac4d07f275a7fba456792ed1b06bed6a44acedb Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 12 Apr 2026 16:16:05 -0700 Subject: [PATCH 4/4] fix: route /models to Copilot API, not GitHub REST API PR #1952 (merged to main) correctly identified that /models is served by the Copilot API at api.githubcopilot.com, not the GitHub REST API. Routing to api.github.com with a Copilot token causes 401, making the Copilot CLI crash silently (exit 1, zero output, 1 second). Revert /models routing to COPILOT_API_TARGET. Keep deriveGitHubApiTarget and deriveGitHubApiBasePath functions for potential future GHES/GHEC use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/api-proxy/server.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index f5399eca..27a8b0c1 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -207,9 +207,9 @@ function deriveCopilotApiTarget() { } const COPILOT_API_TARGET = deriveCopilotApiTarget(); -// GitHub REST API target host for endpoints that live on the REST API, not the Copilot API. -// Used for model validation calls (GET /models) made by Copilot CLI 1.0.21+ when COPILOT_MODEL -// is set. The /models endpoint is part of the GitHub REST API, not the Copilot inference API. +// GitHub REST API target host for endpoints that need the GitHub REST API +// (e.g., enterprise-specific endpoints). Currently unused — /models is served +// by the Copilot API, not the REST API — but kept for future GHES/GHEC needs. // Priority: GITHUB_API_URL env var (hostname extracted) > auto-derive from GITHUB_SERVER_URL > default function deriveGitHubApiTarget() { // Explicit GITHUB_API_URL takes priority — this is the canonical source for enterprise deployments @@ -955,9 +955,9 @@ if (require.main === module) { } const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/'); if (isModelsPath && req.method === 'GET' && COPILOT_GITHUB_TOKEN) { - proxyRequest(req, res, GITHUB_API_TARGET, { + proxyRequest(req, res, COPILOT_API_TARGET, { 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, - }, 'copilot', GITHUB_API_BASE_PATH); + }, 'copilot'); return; }