Skip to content
Merged
2 changes: 1 addition & 1 deletion containers/api-proxy/guards/ai-credits-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
10 changes: 8 additions & 2 deletions docs/sbx-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
47 changes: 47 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 All @@ -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';

Expand Down Expand Up @@ -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<string, string> = { 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 () => {
Expand Down
70 changes: 70 additions & 0 deletions src/sbx-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
workDir?: string,
): Promise<void> {
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.
*/
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');
});
});
22 changes: 21 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 All @@ -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`);
}
Expand All @@ -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) {
Expand Down
Loading
Loading