diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 317f125e..27a8b0c1 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -207,6 +207,53 @@ function deriveCopilotApiTarget() { } const COPILOT_API_TARGET = deriveCopilotApiTarget(); +// 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 + 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'; +} + +/** + * 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; @@ -218,6 +265,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)', @@ -1036,4 +1084,4 @@ if (require.main === module) { } // Export for testing -module.exports = { normalizeApiTarget, deriveCopilotApiTarget, 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 56ce7f02..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, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -165,6 +165,127 @@ 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('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(''); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 00e4803c..2127a384 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1535,8 +1535,8 @@ 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 use the correct GitHub REST API hostname - // on GHES/GHEC (e.g. https://ghes.example.com/api/v3 or api.mycompany.ghe.com) + // 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}`,