Skip to content
Merged
26 changes: 13 additions & 13 deletions src/commands/main-action-coverage-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,30 +105,31 @@ 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',
enableApiProxy: true,
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'),
}),
);
});
});
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/commands/main-action.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ interface MainActionHarnessDeps {
mockedDindBootstrap: Pick<jest.Mocked<typeof dindBootstrap>, 'runDindBootstrap'>;
mockedSignalHandler: Pick<jest.Mocked<typeof signalHandler>, 'registerSignalHandlers'>;
mockedCliWorkflow: Pick<jest.Mocked<typeof cliWorkflow>, 'runMainWorkflow'>;
mockedSbxManager: Pick<jest.Mocked<typeof sbxManager>, 'isSbxAvailable' | 'createSandbox' | 'execInSandbox' | 'removeSandbox'>;
mockedSbxManager: Pick<
jest.Mocked<typeof sbxManager>,
'isSbxAvailable' | 'createSandbox' | 'assertSbxApiProxyReflect' | 'execInSandbox' | 'removeSandbox'
>;
}

export interface MainActionTestHarness {
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions src/commands/main-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 10 additions & 22 deletions src/commands/main-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { assertTopologySupported, connectTopologyContainers } from '../topology'
import { runDindBootstrap } from '../dind-bootstrap';
import { runtimeUsesComposeAgent } from '../container-runtime';
import {
assertSbxApiProxyReflect,
assertSbxBoundedQueryIngress,
createSandbox,
execInSandbox,
Expand Down Expand Up @@ -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(
Comment on lines +409 to +414
sbxName,
sbxEnvironment,
config.containerWorkDir,
);
Comment on lines +413 to +418
}

// Verify squid proxy is reachable from sandbox
Expand Down
10 changes: 9 additions & 1 deletion src/compose-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/sbx-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
assertSbxApiProxyReflect,
assertSbxBoundedQueryIngress,
createSandbox,
execInSandbox,
Expand Down Expand Up @@ -209,6 +210,34 @@ describe('sbx-manager', () => {
env: expect.any(Object),
}));
});

describe('assertSbxApiProxyReflect', () => {
it('installs the api-proxy alias and probes the reflection endpoint', async () => {
mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });

await expect(assertSbxApiProxyReflect(
'awf-agent-test',
{ NO_PROXY: 'api-proxy' },
'/workspace',
)).resolves.toBeUndefined();

const args: string[] = mockExecaFn.mock.calls[0][1];
const command = args[args.length - 1];
expect(command).toContain('host.docker.internal');
expect(command).toContain('api-proxy');
expect(command).toContain('http://api-proxy:10000/reflect');
expect(command).toContain('for attempt in $(seq 1 30)');
});

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 () => {
Expand Down
42 changes: 42 additions & 0 deletions src/sbx-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,48 @@ export async function assertSbxBoundedQueryIngress(
}
}

/**
* Adds the published API proxy to the sandbox's host map and proves that the
* hard-coded gh-aw reflection endpoint is reachable before the agent starts.
*/
export async function assertSbxApiProxyReflect(
name: string,
environment: Record<string, string>,
workDir?: string,
): Promise<void> {
const command = [
'host_ip=$(getent ahostsv4 host.docker.internal | awk \'NR == 1 { print $1 }\')',
'[ -n "$host_ip" ] || exit 1',
[
'if ! getent hosts api-proxy >/dev/null 2>&1; then',
'if [ "$(id -u)" -eq 0 ]; then',
'printf "%s\\tapi-proxy\\n" "$host_ip" >> /etc/hosts;',
'elif command -v sudo >/dev/null 2>&1; then',
'printf "%s\\tapi-proxy\\n" "$host_ip" | sudo tee -a /etc/hosts >/dev/null;',
'else exit 1;',
'fi;',
'fi',
].join(' '),
[
'for attempt in $(seq 1 30); do',
'if curl --silent --show-error --fail --noproxy "*" --max-time 2',
'http://api-proxy:10000/reflect >/dev/null 2>&1; then exit 0; fi;',
'sleep 1;',
'done;',
'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.
*/
Expand Down
19 changes: 19 additions & 0 deletions src/services/agent-volumes-chroot-hosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
16 changes: 15 additions & 1 deletion src/services/agent-volumes/hosts-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string> = {},
): string {
let hostsContent = '127.0.0.1 localhost\n';
try {
hostsContent = fs.readFileSync('/etc/hosts', 'utf-8');
Expand Down Expand Up @@ -39,6 +42,17 @@ 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) {
hostsContent += `${ip}\t${hostname}\n`;
}
}

const shouldInjectHostGateway = config.enableHostAccess &&
!(config.networkIsolation && runtimeUsesComposeAgent(config.containerRuntime));
if (shouldInjectHostGateway) {
Expand Down
15 changes: 13 additions & 2 deletions src/services/agent-volumes/volume-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { buildCustomVolumeMounts, buildWorkspaceMounts } from './workspace-mount

interface AgentVolumesParams {
config: WrapperConfig;
internalServiceHosts?: Record<string, string>;
sslConfig?: SslConfig;
projectRoot: string;
effectiveHome: string;
Expand All @@ -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({
Expand All @@ -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));
Expand Down
Loading