diff --git a/containers/api-proxy/guards/ai-credits-guard.test.js b/containers/api-proxy/guards/ai-credits-guard.test.js index 3d65208d1..fc40594ba 100644 --- a/containers/api-proxy/guards/ai-credits-guard.test.js +++ b/containers/api-proxy/guards/ai-credits-guard.test.js @@ -664,7 +664,7 @@ describe('ai-credits-guard', () => { expect(sonnet5).toBeNull(); }); - it('rejects the Copilot auto selector when runtime pricing cannot be proven', () => { + it('rejects the auto selector when runtime pricing cannot be proven', () => { process.env.AWF_MAX_AI_CREDITS = '10'; resetAiCreditsGuardForTests(); diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 8e41a9c52..a30b1b567 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -280,8 +280,14 @@ and, when true, substitutes two functions into the shared workflow runner: mounts only its skill/wrapper directory (plus the socket directory when Unix passthrough was proven), and probes reachability before agent startup. 4. Calls `createSandbox({ workspaceDir, squidIp: SQUID_IP, extraMounts })`. - 5. Polls api-proxy health (via `host.docker.internal:10000/health`) since - there is no compose `depends_on` gate across the VM boundary. + 5. When the api-proxy is enabled, runs `assertSbxApiProxyReflect`: creates a + private `HOSTALIASES` resolver file mapping `api-proxy` to a loopback HTTP + bridge. The bridge forwards to `host.docker.internal:10000` with the host + header expected by docker-sbx, then AWF probes + `http://api-proxy:10000/reflect` with Node's built-in `fetch` and up to 30 + retries. Startup **aborts** (fail-closed) if the endpoint is + unreachable after all retries, because an isolated runtime that cannot reach + `/reflect` cannot do model auto-resolution or credit accounting. 6. Runs a Squid connectivity diagnostic (`curl --proxy ... https://api.github.com`). - **`sbxRunAgentCommand`** runs the actual agent command with `execInSandbox`, honoring the agent timeout, workdir, TTY, and computed environment, and dumps diff --git a/src/commands/main-action-coverage-gaps.test.ts b/src/commands/main-action-coverage-gaps.test.ts index dd4b585a7..049c7a56a 100644 --- a/src/commands/main-action-coverage-gaps.test.ts +++ b/src/commands/main-action-coverage-gaps.test.ts @@ -105,8 +105,8 @@ describe('createMainAction coverage gaps', () => { }); }); - describe('sbx: api-proxy health check failure proceeds anyway', () => { - it('logs warning when api-proxy health check fails but continues', async () => { + describe('sbx: api-proxy reflection preflight failure', () => { + it('fails closed when /reflect is unreachable', async () => { const sbxConfig = { ...MAIN_ACTION_STUB_CONFIG, containerRuntime: 'sbx', @@ -114,21 +114,22 @@ describe('createMainAction coverage gaps', () => { containerWorkDir: '/workspace', } as unknown as import('../types').WrapperConfig; mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); - mockedSbxManager.execInSandbox - .mockResolvedValueOnce({ exitCode: 1 }) // api-proxy health check fails - .mockResolvedValueOnce({ exitCode: 1 }) // squid connectivity check - .mockResolvedValueOnce({ exitCode: 0 }); // agent command + mockedSbxManager.assertSbxApiProxyReflect.mockRejectedValue( + new Error('sbx sandbox cannot reach the API proxy /reflect endpoint'), + ); mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { await deps.startContainers('/tmp/awf-test', ['github.com']); - const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com'], undefined, 5); - return result.exitCode; + return 0; }); const action = createMainAction(getOptionValueSource); - await action(['echo hi'], {}); + await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); - expect(mockedLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('api-proxy health check failed'), + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Fatal error:', + expect.objectContaining({ + message: expect.stringContaining('/reflect'), + }), ); }); }); @@ -143,9 +144,8 @@ describe('createMainAction coverage gaps', () => { } as unknown as import('../types').WrapperConfig; mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); - // api-proxy health check succeeds, squid diag succeeds, agent fails + // Reflection preflight is mocked separately; squid diag succeeds, agent fails. mockedSbxManager.execInSandbox - .mockResolvedValueOnce({ exitCode: 0 }) // api-proxy health .mockResolvedValueOnce({ exitCode: 0 }) // squid diag .mockResolvedValueOnce({ exitCode: 42 }); // agent command fails mockExecSync diff --git a/src/commands/main-action.test-utils.ts b/src/commands/main-action.test-utils.ts index 7d9de1f46..44c60e220 100644 --- a/src/commands/main-action.test-utils.ts +++ b/src/commands/main-action.test-utils.ts @@ -37,7 +37,10 @@ interface MainActionHarnessDeps { mockedDindBootstrap: Pick, 'runDindBootstrap'>; mockedSignalHandler: Pick, 'registerSignalHandlers'>; mockedCliWorkflow: Pick, 'runMainWorkflow'>; - mockedSbxManager: Pick, 'isSbxAvailable' | 'createSandbox' | 'execInSandbox' | 'removeSandbox'>; + mockedSbxManager: Pick< + jest.Mocked, + 'isSbxAvailable' | 'createSandbox' | 'assertSbxApiProxyReflect' | 'execInSandbox' | 'removeSandbox' + >; } export interface MainActionTestHarness { @@ -79,6 +82,7 @@ export function setupMainActionTestHarness(deps: MainActionHarnessDeps): MainAct deps.mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0); deps.mockedSbxManager.isSbxAvailable.mockResolvedValue(true); deps.mockedSbxManager.createSandbox.mockResolvedValue('awf-agent-test'); + deps.mockedSbxManager.assertSbxApiProxyReflect.mockResolvedValue(undefined); deps.mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); deps.mockedSbxManager.removeSandbox.mockResolvedValue(undefined); diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 04617c1b5..3d5b09e6a 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -308,6 +308,13 @@ describe('createMainAction', () => { expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith(expect.objectContaining({ extraMounts: ['/tmp/tooling:/tmp/tooling:ro'], })); + expect(mockedSbxManager.assertSbxApiProxyReflect).toHaveBeenCalledWith( + 'awf-agent-test', + expect.objectContaining({ + NO_PROXY: expect.stringContaining('api-proxy'), + }), + '/home/runner/work/repo/repo', + ); expect(mockedSbxManager.execInSandbox).toHaveBeenCalledWith( 'awf-agent-test', 'echo hi', diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 5f2bf3c6e..166c2a5ad 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -28,6 +28,7 @@ import { assertTopologySupported, connectTopologyContainers } from '../topology' import { runDindBootstrap } from '../dind-bootstrap'; import { runtimeUsesComposeAgent } from '../container-runtime'; import { + assertSbxApiProxyReflect, assertSbxBoundedQueryIngress, createSandbox, execInSandbox, @@ -405,29 +406,16 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } } - // Wait for api-proxy to be healthy before launching agent. - // In Docker mode, depends_on: service_healthy gates this; for sbx we poll - // via host.docker.internal which resolves to the docker0 bridge from the VM. + // gh-aw fetches reflection data from the fixed api-proxy hostname. The + // microVM reaches the sidecar through its published host ports, so install + // that alias and prove the real endpoint before launching the agent. if (config.enableApiProxy) { - logger.info('[sbx] Polling api-proxy health via host.docker.internal...'); - const healthCmd = [ - 'for i in $(seq 1 30); do', - ` if curl -sf --max-time 2 http://${SBX_HOST_DOCKER_INTERNAL}:10000/health >/dev/null 2>&1; then`, - ' echo "api-proxy healthy after ${i}s"; exit 0;', - ' fi;', - ' sleep 1;', - 'done;', - 'echo "api-proxy health timeout"; exit 1', - ].join(' '); - - const healthResult = await execInSandbox(sbxName, healthCmd, { - timeoutMinutes: 1, - workDir: config.containerWorkDir, - environment: sbxEnvironment, - }); - if (healthResult.exitCode !== 0) { - logger.warn('[sbx] api-proxy health check failed — proceeding anyway'); - } + logger.info('[sbx] Verifying api-proxy /reflect access...'); + await assertSbxApiProxyReflect( + sbxName, + sbxEnvironment, + config.containerWorkDir, + ); } // Verify squid proxy is reachable from sandbox diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 0e4920e69..8daf0fec1 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -10,10 +10,11 @@ import { buildSquidService } from './services/squid-service'; import { buildAgentEnvironment, buildAgentVolumes, buildAgentService } from './services/agent-service'; import { assembleOptionalServices } from './services/optional-services'; import { buildComposeNetworks } from './compose-network'; -import { runtimeUsesComposeAgent } from './container-runtime'; +import { runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runtime'; import { API_PROXY_PORTS } from './types/ports'; import { EXTERNAL_BRIDGE_NAME } from './config/network-policy'; import { BOUNDED_QUERY_INGRESS_NETWORK } from './bounded-query/ingress'; +import { buildInternalServiceHosts } from './services/internal-service-hosts'; /** * Generates Docker Compose configuration @@ -89,6 +90,13 @@ export function generateDockerCompose( const agentVolumes = buildAgentVolumes({ config, + internalServiceHosts: runtimeNeedsStaticDns(config.containerRuntime) + ? buildInternalServiceHosts({ + squidIp: networkConfig.squidIp, + apiProxyIp: networkConfig.proxyIp, + cliProxyIp: networkConfig.cliProxyIp, + }) + : undefined, sslConfig, projectRoot, effectiveHome, diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index 3f0a3c236..9e2a33051 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -1,4 +1,5 @@ import { + assertSbxApiProxyReflect, assertSbxBoundedQueryIngress, createSandbox, execInSandbox, @@ -9,6 +10,7 @@ import { testHelpers, } from './sbx-manager'; import * as fs from 'fs'; +import { spawnSync } from 'child_process'; import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; import { logger } from './logger'; @@ -209,6 +211,51 @@ describe('sbx-manager', () => { env: expect.any(Object), })); }); + + describe('assertSbxApiProxyReflect', () => { + it('installs a resolver alias and probes the reflection endpoint with Node fetch', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + const environment: Record = { NO_PROXY: 'api-proxy' }; + + await expect(assertSbxApiProxyReflect( + 'awf-agent-test', + environment, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(environment.HOSTALIASES).toBe('/tmp/awf-hostaliases'); + expect(command).toContain( + 'printf "api-proxy localhost\\n" > "$HOSTALIASES"', + ); + expect(command).toContain('base64 --decode > /tmp/awf-reflect-bridge.cjs'); + expect(command).toContain('nohup node /tmp/awf-reflect-bridge.cjs'); + const encodedBridge = command.match(/printf %s ([A-Za-z0-9+/=]+) \| base64/)?.[1]; + expect(encodedBridge).toBeDefined(); + const bridgeSource = Buffer.from(encodedBridge!, 'base64').toString('utf8'); + expect(bridgeSource).toContain('host: `${upstreamHost}:10000`'); + expect(() => new Function('require', bridgeSource)).not.toThrow(); + expect(command).toContain('http://api-proxy:10000/reflect'); + expect(command).toContain('node -e'); + expect(command).toContain('console.error(error, error.cause)'); + expect(command).toContain('AbortSignal.timeout(500)'); + expect(command).toContain('cat /tmp/awf-reflect-bridge.log'); + expect(command).toContain('for attempt in $(seq 1 30)'); + expect(command).toContain('exit 1; }'); + expect(command).not.toContain('/etc/hosts'); + expect(spawnSync('bash', ['-n', '-c', command]).status).toBe(0); + }); + + it('fails closed when the reflection endpoint is unreachable', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(assertSbxApiProxyReflect( + 'awf-agent-test', + {}, + )).rejects.toThrow('cannot reach the API proxy /reflect endpoint'); + }); + }); }); it('uses shell agent, configured mounts, and sanitized env', async () => { diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index 5cb251d75..dff0b2ef1 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -581,6 +581,76 @@ export async function assertSbxBoundedQueryIngress( } } +/** + * Adds a resolver alias for the published API proxy and proves that the + * hard-coded gh-aw reflection endpoint is reachable before the agent starts. + */ +export async function assertSbxApiProxyReflect( + name: string, + environment: Record, + workDir?: string, +): Promise { + environment.HOSTALIASES = '/tmp/awf-hostaliases'; + const bridgeSource = [ + 'const http = require("node:http");', + 'const upstreamHost = "host.docker.internal";', + 'http.createServer((request, response) => {', + 'const upstream = http.request({', + 'hostname: upstreamHost,', + 'port: 10000,', + 'method: request.method,', + 'path: request.url,', + 'headers: { ...request.headers, host: `${upstreamHost}:10000` },', + '}, upstreamResponse => {', + 'response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);', + 'upstreamResponse.pipe(response);', + '});', + 'upstream.on("error", error => {', + 'if (!response.headersSent) response.writeHead(502);', + 'response.end(error.message);', + '});', + 'request.pipe(upstream);', + '}).listen(10000, "127.0.0.1");', + ].join('\n'); + const encodedBridge = Buffer.from(bridgeSource).toString('base64'); + const command = [ + 'umask 077', + 'printf "api-proxy localhost\\n" > "$HOSTALIASES"', + `printf %s ${encodedBridge} | base64 --decode > /tmp/awf-reflect-bridge.cjs`, + '{ nohup node /tmp/awf-reflect-bridge.cjs >/tmp/awf-reflect-bridge.log 2>&1 & }', + [ + '{', + 'for attempt in $(seq 1 30); do', + 'if AWF_REFLECT_ATTEMPT="$attempt" node -e', + '\'fetch("http://api-proxy:10000/reflect", { signal: AbortSignal.timeout(500) }).then(', + 'async response => {', + 'if (!response.ok && process.env.AWF_REFLECT_ATTEMPT === "30")', + 'console.error(`HTTP ${response.status}: ${await response.text()}`);', + 'process.exit(response.ok ? 0 : 1);', + '}', + ').catch(error => {', + 'if (process.env.AWF_REFLECT_ATTEMPT === "30") console.error(error, error.cause);', + 'process.exit(1);', + '})\'', + '; then exit 0; fi;', + 'sleep 1;', + 'done;', + 'cat /tmp/awf-reflect-bridge.log >&2 || true;', + 'exit 1;', + '}', + ].join(' '), + ].join(' && '); + + const result = await execInSandbox(name, command, { + timeoutMinutes: 1, + workDir, + environment, + }); + if (result.exitCode !== 0) { + throw new Error('sbx sandbox cannot reach the API proxy /reflect endpoint'); + } +} + /** * Stops and removes the sandbox. */ diff --git a/src/services/agent-volumes-chroot-hosts.test.ts b/src/services/agent-volumes-chroot-hosts.test.ts index 814dc5e8e..c9993c9c3 100644 --- a/src/services/agent-volumes-chroot-hosts.test.ts +++ b/src/services/agent-volumes-chroot-hosts.test.ts @@ -147,4 +147,23 @@ describe('agent service', () => { expect(hostsVolume).toBeDefined(); expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); }); + + it('should inject api-proxy into chroot hosts for gVisor', () => { + const config = { + ...getConfig(), + containerRuntime: 'gvisor', + enableApiProxy: true, + }; + const networkConfig = { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }; + const result = generateDockerCompose(config, networkConfig); + const hostsVolume = (result.services.agent.volumes as string[]) + .find((volume) => volume.includes('/host/etc/hosts')); + + expect(hostsVolume).toBeDefined(); + const hostsPath = hostsVolume!.split(':')[0]; + expect(fs.readFileSync(hostsPath, 'utf8')).toContain('172.30.0.30\tapi-proxy'); + }); }); diff --git a/src/services/agent-volumes/hosts-file.ts b/src/services/agent-volumes/hosts-file.ts index 74641d0f1..77e9aaa3b 100644 --- a/src/services/agent-volumes/hosts-file.ts +++ b/src/services/agent-volumes/hosts-file.ts @@ -9,7 +9,10 @@ import { getDockerHostStageRoot, shouldUseDockerHostStaging } from './docker-hos const STALE_CHROOT_STAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000; -export function generateHostsFileMount(config: WrapperConfig): string { +export function generateHostsFileMount( + config: WrapperConfig, + internalServiceHosts: Record = {}, +): string { let hostsContent = '127.0.0.1 localhost\n'; try { hostsContent = fs.readFileSync('/etc/hosts', 'utf-8'); @@ -31,6 +34,9 @@ export function generateHostsFileMount(config: WrapperConfig): string { const parts = stdout.trim().split(/\s+/); const ip = parts[0]; if (ip) { + if (hostsContent.length > 0 && !hostsContent.endsWith('\n')) { + hostsContent += '\n'; + } hostsContent += `${ip}\t${domain}\n`; logger.debug(`Pre-resolved ${domain} -> ${ip} for chroot /etc/hosts`); } @@ -39,6 +45,20 @@ export function generateHostsFileMount(config: WrapperConfig): string { } } + for (const [hostname, ip] of Object.entries(internalServiceHosts)) { + const alreadyPresent = hostsContent.split('\n').some(line => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return false; + return trimmed.split(/\s+/).slice(1).includes(hostname); + }); + if (!alreadyPresent) { + if (hostsContent.length > 0 && !hostsContent.endsWith('\n')) { + hostsContent += '\n'; + } + hostsContent += `${ip}\t${hostname}\n`; + } + } + const shouldInjectHostGateway = config.enableHostAccess && !(config.networkIsolation && runtimeUsesComposeAgent(config.containerRuntime)); if (shouldInjectHostGateway) { diff --git a/src/services/agent-volumes/volume-builder.ts b/src/services/agent-volumes/volume-builder.ts index 03b7409f6..a551e076a 100644 --- a/src/services/agent-volumes/volume-builder.ts +++ b/src/services/agent-volumes/volume-builder.ts @@ -13,6 +13,7 @@ import { buildCustomVolumeMounts, buildWorkspaceMounts } from './workspace-mount interface AgentVolumesParams { config: WrapperConfig; + internalServiceHosts?: Record; sslConfig?: SslConfig; projectRoot: string; effectiveHome: string; @@ -23,7 +24,17 @@ interface AgentVolumesParams { } export function buildAgentVolumes(params: AgentVolumesParams): string[] { - const { config, sslConfig, projectRoot, effectiveHome, workspaceDir, agentLogsPath, sessionStatePath, initSignalDir } = params; + const { + config, + internalServiceHosts, + sslConfig, + projectRoot, + effectiveHome, + workspaceDir, + agentLogsPath, + sessionStatePath, + initSignalDir, + } = params; const agentVolumes: string[] = []; agentVolumes.push(...buildWorkspaceMounts({ @@ -47,7 +58,7 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] { // Docker daemon cannot see on split-fs. DNS pre-resolution is skipped; // the agent resolves domains at runtime via the container's DNS config. if (!useSysroot) { - agentVolumes.push(generateHostsFileMount(config)); + agentVolumes.push(generateHostsFileMount(config, internalServiceHosts)); } agentVolumes.push(...buildDockerSocketMount(config)); agentVolumes.push(...buildSslMounts(sslConfig));