Skip to content

Commit b5c88f3

Browse files
CopilotlpcoxCopilotgithub-advanced-security[bot]
authored
refactor: Split compose-generator.ts into focused service builders (#2558)
* Initial plan * refactor: split compose-generator.ts into focused service builders - Extract src/network-allocator.ts (~68 lines): subnet allocation helpers - Create src/services/squid-service.ts (~134 lines): Squid proxy service builder - Create src/services/agent-service.ts (~1099 lines): agent env, volumes, service + iptables-init - Create src/services/api-proxy-service.ts (~261 lines): API proxy service builder - Create src/services/doh-proxy-service.ts (~48 lines): DoH proxy service builder - Create src/services/cli-proxy-service.ts (~117 lines): CLI proxy service builder - Reduce src/compose-generator.ts to 253 lines thin orchestration facade (down from 1,651) All 1747 tests pass. No TypeScript or lint errors." Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/5c906a4c-d75e-4981-96ac-b575c9f5dc28 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * merge: main into PR branch; fix broken import after function rename --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 32d6e48 commit b5c88f3

7 files changed

Lines changed: 1819 additions & 1493 deletions

File tree

src/compose-generator.ts

Lines changed: 92 additions & 1493 deletions
Large diffs are not rendered by default.

src/network-allocator.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import execa from 'execa';
2+
import { logger } from './logger';
3+
import { getLocalDockerEnv, subnetsOverlap } from './host-env';
4+
5+
export async function getExistingDockerSubnets(): Promise<string[]> {
6+
try {
7+
// Get all network IDs
8+
const { stdout: networkIds } = await execa('docker', ['network', 'ls', '-q'], { env: getLocalDockerEnv() });
9+
if (!networkIds.trim()) {
10+
return [];
11+
}
12+
13+
// Get subnet information for each network
14+
const { stdout } = await execa('docker', [
15+
'network',
16+
'inspect',
17+
'--format={{range .IPAM.Config}}{{.Subnet}} {{end}}',
18+
...networkIds.trim().split('\n'),
19+
], { env: getLocalDockerEnv() });
20+
21+
// Parse subnets from output (format: "172.17.0.0/16 172.18.0.0/16 ")
22+
const subnets = stdout
23+
.split(/\s+/)
24+
.filter((s) => s.includes('/'))
25+
.map((s) => s.trim());
26+
27+
logger.debug(`Found existing Docker subnets: ${subnets.join(', ')}`);
28+
return subnets;
29+
} catch {
30+
logger.debug('Failed to query Docker networks, proceeding with random subnet');
31+
return [];
32+
}
33+
}
34+
35+
/**
36+
* Generates a random subnet in Docker's private IP range that doesn't conflict with existing networks
37+
* Uses 172.16-31.x.0/24 range (Docker's default bridge network range)
38+
* @internal
39+
*/
40+
export async function generateRandomSubnet(): Promise<{ subnet: string; squidIp: string; agentIp: string }> {
41+
const existingSubnets = await getExistingDockerSubnets();
42+
const MAX_RETRIES = 50;
43+
44+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
45+
// Use 172.16-31.x.0/24 range
46+
const secondOctet = Math.floor(Math.random() * 16) + 16; // 16-31
47+
const thirdOctet = Math.floor(Math.random() * 256); // 0-255
48+
const subnet = `172.${secondOctet}.${thirdOctet}.0/24`;
49+
50+
// Check for conflicts with existing subnets
51+
const hasConflict = existingSubnets.some((existingSubnet) =>
52+
subnetsOverlap(subnet, existingSubnet)
53+
);
54+
55+
if (!hasConflict) {
56+
const squidIp = `172.${secondOctet}.${thirdOctet}.10`;
57+
const agentIp = `172.${secondOctet}.${thirdOctet}.20`;
58+
return { subnet, squidIp, agentIp };
59+
}
60+
61+
logger.debug(`Subnet ${subnet} conflicts with existing network, retrying... (attempt ${attempt + 1}/${MAX_RETRIES})`);
62+
}
63+
64+
throw new Error(
65+
`Failed to generate non-conflicting subnet after ${MAX_RETRIES} attempts. ` +
66+
`Existing subnets: ${existingSubnets.join(', ')}`
67+
);
68+
}

src/services/agent-service.ts

Lines changed: 1099 additions & 0 deletions
Large diffs are not rendered by default.

src/services/api-proxy-service.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import * as path from 'path';
2+
import {
3+
API_PROXY_CONTAINER_NAME,
4+
SQUID_PORT,
5+
stripScheme,
6+
} from '../host-env';
7+
import { buildRuntimeImageRef } from '../image-tag';
8+
import { logger } from '../logger';
9+
import { WrapperConfig, API_PROXY_PORTS, API_PROXY_HEALTH_PORT } from '../types';
10+
import { NetworkConfig, ImageBuildConfig } from './squid-service';
11+
12+
export interface ApiProxyBuildResult {
13+
/** The api-proxy service definition to add to Docker Compose services. */
14+
service: any;
15+
/**
16+
* Additional environment variables to merge into the agent container's environment.
17+
* These set placeholder API keys and base URLs so the agent routes traffic through
18+
* the sidecar instead of calling upstream APIs directly.
19+
*/
20+
agentEnvAdditions: Record<string, string>;
21+
}
22+
23+
export interface ApiProxyServiceParams {
24+
config: WrapperConfig;
25+
networkConfig: NetworkConfig;
26+
apiProxyLogsPath: string;
27+
imageConfig: ImageBuildConfig;
28+
}
29+
30+
/**
31+
* Builds the API proxy sidecar service configuration and associated agent environment
32+
* mutations required for credential isolation.
33+
*/
34+
export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBuildResult {
35+
const { config, networkConfig, apiProxyLogsPath, imageConfig } = params;
36+
const { useGHCR, registry, parsedTag, projectRoot } = imageConfig;
37+
38+
if (!networkConfig.proxyIp) {
39+
throw new Error('buildApiProxyService: networkConfig.proxyIp is required');
40+
}
41+
42+
const proxyService: any = {
43+
container_name: API_PROXY_CONTAINER_NAME,
44+
networks: {
45+
'awf-net': {
46+
ipv4_address: networkConfig.proxyIp,
47+
},
48+
},
49+
volumes: [
50+
// Mount log directory for api-proxy logs
51+
`${apiProxyLogsPath}:/var/log/api-proxy:rw`,
52+
],
53+
environment: {
54+
// Pass API keys securely to sidecar (not visible to agent)
55+
...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }),
56+
...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }),
57+
...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }),
58+
...(config.copilotApiKey && { COPILOT_API_KEY: config.copilotApiKey }),
59+
...(config.geminiApiKey && { GEMINI_API_KEY: config.geminiApiKey }),
60+
// Configurable API targets (for GHES/GHEC / custom endpoints)
61+
// Strip any scheme prefix — server.js also normalizes defensively, but
62+
// stripping here prevents a scheme-prefixed hostname from reaching the
63+
// container at all (belt-and-suspenders for gh-aw#25137).
64+
...(config.copilotApiTarget && { COPILOT_API_TARGET: stripScheme(config.copilotApiTarget) }),
65+
...(config.openaiApiTarget && { OPENAI_API_TARGET: stripScheme(config.openaiApiTarget) }),
66+
...(config.openaiApiBasePath && { OPENAI_API_BASE_PATH: config.openaiApiBasePath }),
67+
...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: stripScheme(config.anthropicApiTarget) }),
68+
...(config.anthropicApiBasePath && { ANTHROPIC_API_BASE_PATH: config.anthropicApiBasePath }),
69+
...(config.geminiApiTarget && { GEMINI_API_TARGET: stripScheme(config.geminiApiTarget) }),
70+
...(config.geminiApiBasePath && { GEMINI_API_BASE_PATH: config.geminiApiBasePath }),
71+
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
72+
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
73+
// Forward GITHUB_API_URL so api-proxy can route /models to the correct GitHub REST API
74+
// target on GHES/GHEC (e.g. api.mycompany.ghe.com instead of api.github.com)
75+
...(process.env.GITHUB_API_URL && { GITHUB_API_URL: process.env.GITHUB_API_URL }),
76+
// Note: AWF_VERSION is intentionally NOT forwarded here. It is baked into the api-proxy
77+
// container image at release build time (via --build-arg AWF_VERSION=...), so the
78+
// token-usage.jsonl _schema field reflects the api-proxy image version rather than
79+
// the CLI version. This ensures correct versioning when --image-tag pins the proxy
80+
// to a different release.
81+
// Route through Squid to respect domain whitelisting
82+
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
83+
HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
84+
https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
85+
// Prevent curl health check from routing localhost through Squid
86+
NO_PROXY: `localhost,127.0.0.1,::1`,
87+
no_proxy: `localhost,127.0.0.1,::1`,
88+
// Rate limiting configuration
89+
...(config.rateLimitConfig && {
90+
AWF_RATE_LIMIT_ENABLED: String(config.rateLimitConfig.enabled),
91+
AWF_RATE_LIMIT_RPM: String(config.rateLimitConfig.rpm),
92+
AWF_RATE_LIMIT_RPH: String(config.rateLimitConfig.rph),
93+
AWF_RATE_LIMIT_BYTES_PM: String(config.rateLimitConfig.bytesPm),
94+
}),
95+
// Model alias configuration
96+
...(config.modelAliases && {
97+
AWF_MODEL_ALIASES: JSON.stringify({ models: config.modelAliases }),
98+
}),
99+
// Anthropic prompt-cache optimizations
100+
...(config.anthropicAutoCache && {
101+
AWF_ANTHROPIC_AUTO_CACHE: '1',
102+
...(config.anthropicCacheTailTtl && { AWF_ANTHROPIC_CACHE_TAIL_TTL: config.anthropicCacheTailTtl }),
103+
}),
104+
// Enable OpenCode listener only when explicitly requested
105+
...(config.enableOpenCode && { AWF_ENABLE_OPENCODE: 'true' }),
106+
// Anthropic request optimisations (all opt-in via env vars on the host)
107+
...(process.env.AWF_ANTHROPIC_AUTO_CACHE && { AWF_ANTHROPIC_AUTO_CACHE: process.env.AWF_ANTHROPIC_AUTO_CACHE }),
108+
...(process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL && { AWF_ANTHROPIC_CACHE_TAIL_TTL: process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL }),
109+
...(process.env.AWF_ANTHROPIC_DROP_TOOLS && { AWF_ANTHROPIC_DROP_TOOLS: process.env.AWF_ANTHROPIC_DROP_TOOLS }),
110+
...(process.env.AWF_ANTHROPIC_STRIP_ANSI && { AWF_ANTHROPIC_STRIP_ANSI: process.env.AWF_ANTHROPIC_STRIP_ANSI }),
111+
// NOTE: AWF_ANTHROPIC_TRANSFORM_FILE is intentionally NOT forwarded from the host.
112+
// The api-proxy container holds live API credentials; loading arbitrary host-side JS
113+
// files into it would create an arbitrary-code-execution risk. If you need a custom
114+
// transform, bake your hook.js into a custom container image and set the env var
115+
// directly in that image's Dockerfile / entrypoint — do NOT forward from the host.
116+
},
117+
healthcheck: {
118+
test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`],
119+
interval: '2s',
120+
timeout: '3s',
121+
retries: 15,
122+
start_period: '30s',
123+
},
124+
// Security hardening: Drop all capabilities
125+
cap_drop: ['ALL'],
126+
security_opt: [
127+
'no-new-privileges:true',
128+
],
129+
// Resource limits to prevent DoS attacks
130+
mem_limit: '512m',
131+
memswap_limit: '512m',
132+
pids_limit: 100,
133+
cpu_shares: 512,
134+
stop_grace_period: '2s',
135+
};
136+
137+
// Use GHCR image or build locally
138+
if (useGHCR) {
139+
proxyService.image = buildRuntimeImageRef(registry, 'api-proxy', parsedTag);
140+
} else {
141+
proxyService.build = {
142+
context: path.join(projectRoot, 'containers/api-proxy'),
143+
dockerfile: 'Dockerfile',
144+
};
145+
}
146+
147+
// Build the agent environment additions for credential isolation
148+
const agentEnvAdditions: Record<string, string> = {
149+
// AWF_API_PROXY_IP is used by setup-iptables.sh to allow agent→api-proxy traffic
150+
// Use IP address instead of hostname for BASE_URLs since Docker DNS may not resolve
151+
// container names in chroot mode
152+
AWF_API_PROXY_IP: networkConfig.proxyIp,
153+
};
154+
155+
if (config.openaiApiKey) {
156+
agentEnvAdditions.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}`;
157+
logger.debug(`OpenAI API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}`);
158+
if (config.openaiApiTarget) {
159+
logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`);
160+
}
161+
if (config.openaiApiBasePath) {
162+
logger.debug(`OpenAI API base path set to: ${config.openaiApiBasePath}`);
163+
}
164+
165+
// Inject placeholder API keys for OpenAI/Codex credential isolation.
166+
// Codex v0.121+ introduced a CODEX_API_KEY-based WebSocket auth flow: when no
167+
// API key is found in the agent env, Codex bypasses OPENAI_BASE_URL and connects
168+
// directly to api.openai.com for OAuth, getting a 401. With a placeholder key
169+
// present, Codex routes API calls through OPENAI_BASE_URL (the api-proxy sidecar),
170+
// which replaces the Authorization header with the real key before forwarding.
171+
// The real keys are held securely in the sidecar; when requests are routed
172+
// through api-proxy, these placeholders are expected to be overwritten by the
173+
// api-proxy's injectHeaders before forwarding upstream.
174+
agentEnvAdditions.OPENAI_API_KEY = 'sk-placeholder-for-api-proxy';
175+
agentEnvAdditions.CODEX_API_KEY = 'sk-placeholder-for-api-proxy';
176+
logger.debug('OPENAI_API_KEY and CODEX_API_KEY set to placeholder values for credential isolation');
177+
}
178+
if (config.anthropicApiKey) {
179+
agentEnvAdditions.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`;
180+
logger.debug(`Anthropic API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`);
181+
if (config.anthropicApiTarget) {
182+
logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`);
183+
}
184+
if (config.anthropicApiBasePath) {
185+
logger.debug(`Anthropic API base path set to: ${config.anthropicApiBasePath}`);
186+
}
187+
188+
// Set placeholder token for Claude Code CLI compatibility
189+
// Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy
190+
// Use sk-ant- prefix so Claude Code's key-format validation passes
191+
agentEnvAdditions.ANTHROPIC_AUTH_TOKEN = 'sk-ant-placeholder-key-for-credential-isolation';
192+
logger.debug('ANTHROPIC_AUTH_TOKEN set to placeholder value for credential isolation');
193+
194+
// Set API key helper for Claude Code CLI to use credential isolation
195+
// The helper script returns a placeholder key; real authentication happens via ANTHROPIC_BASE_URL
196+
agentEnvAdditions.CLAUDE_CODE_API_KEY_HELPER = '/usr/local/bin/get-claude-key.sh';
197+
logger.debug('Claude Code API key helper configured: /usr/local/bin/get-claude-key.sh');
198+
}
199+
if (config.copilotGithubToken || config.copilotApiKey) {
200+
agentEnvAdditions.COPILOT_API_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`;
201+
logger.debug(`GitHub Copilot API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`);
202+
if (config.copilotApiTarget) {
203+
logger.debug(`Copilot API target overridden to: ${config.copilotApiTarget}`);
204+
}
205+
206+
// Set placeholder token for GitHub Copilot CLI compatibility
207+
// Real authentication happens via COPILOT_API_URL pointing to api-proxy
208+
agentEnvAdditions.COPILOT_TOKEN = 'placeholder-token-for-credential-isolation';
209+
logger.debug('COPILOT_TOKEN set to placeholder value for credential isolation');
210+
211+
// Note: COPILOT_GITHUB_TOKEN and COPILOT_API_KEY placeholders are set early (before --env-all)
212+
// to prevent override by host environment variable
213+
}
214+
if (config.copilotApiKey) {
215+
// Enable Copilot CLI offline + BYOK mode so it skips the GitHub OAuth handshake
216+
// and talks directly to the sidecar without needing GitHub authentication for inference.
217+
// Reference: https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/
218+
agentEnvAdditions.COPILOT_OFFLINE = 'true';
219+
logger.debug('COPILOT_OFFLINE set to true for offline+BYOK mode');
220+
221+
// Point Copilot CLI's BYOK provider URL at the sidecar, which injects the real API key
222+
// and forwards the request through Squid. This is the new canonical BYOK env var.
223+
agentEnvAdditions.COPILOT_PROVIDER_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`;
224+
logger.debug(`COPILOT_PROVIDER_BASE_URL set to sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`);
225+
226+
// COPILOT_PROVIDER_API_KEY placeholder: real key is held by the sidecar, never exposed to agent.
227+
// Set early placeholder (before this block) already handled above.
228+
logger.debug('COPILOT_PROVIDER_API_KEY placeholder set for credential isolation');
229+
}
230+
// Only configure Gemini proxy routing when a Gemini API key is provided.
231+
// Previously this was unconditional, which caused the Gemini CLI's ~/.gemini
232+
// directory and GEMINI_API_KEY placeholder to appear in non-Gemini runs (e.g.
233+
// Copilot-only runs), producing suspicious-looking log entries.
234+
if (config.geminiApiKey) {
235+
const geminiProxyUrl = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.GEMINI}`;
236+
// GOOGLE_GEMINI_BASE_URL is the env var read by the Gemini CLI (google-gemini/gemini-cli)
237+
// when authType === USE_GEMINI. Setting it routes all Gemini CLI traffic through
238+
// the api-proxy sidecar instead of calling generativelanguage.googleapis.com directly.
239+
agentEnvAdditions.GOOGLE_GEMINI_BASE_URL = geminiProxyUrl;
240+
// GEMINI_API_BASE_URL is kept for backward compatibility with older SDK versions
241+
// and other tools that may read it (e.g. @google/generative-ai npm package).
242+
agentEnvAdditions.GEMINI_API_BASE_URL = geminiProxyUrl;
243+
logger.debug(`Google Gemini API will be proxied through sidecar at ${geminiProxyUrl}`);
244+
if (config.geminiApiTarget) {
245+
logger.debug(`Gemini API target overridden to: ${config.geminiApiTarget}`);
246+
}
247+
if (config.geminiApiBasePath) {
248+
logger.debug(`Gemini API base path set to: ${config.geminiApiBasePath}`);
249+
}
250+
251+
// Set placeholder key so Gemini CLI's startup auth check passes (exit code 41).
252+
// Real authentication happens via GOOGLE_GEMINI_BASE_URL / GEMINI_API_BASE_URL pointing to api-proxy.
253+
agentEnvAdditions.GEMINI_API_KEY = 'gemini-api-key-placeholder-for-credential-isolation';
254+
logger.debug('GEMINI_API_KEY set to placeholder value for credential isolation');
255+
}
256+
257+
logger.info('API proxy sidecar enabled - API keys will be held securely in sidecar container');
258+
logger.info('API proxy will route through Squid to respect domain whitelisting');
259+
260+
return { service: proxyService, agentEnvAdditions };
261+
}

0 commit comments

Comments
 (0)