From d3c0ac056fa133d7fef25abf616112558b972433 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:06:18 +0000 Subject: [PATCH 1/7] Initial plan From 9647f8cc14db98d734916d0062b2ba7da1b74d63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:17:32 +0000 Subject: [PATCH 2/7] feat: wire routing into host workflow --- src/cli-workflow.test.ts | 65 +++ src/cli-workflow.ts | 41 +- src/commands/main-action.ts | 17 +- src/compose-generator.test.ts | 75 ++++ src/compose-generator.ts | 8 + src/constants.ts | 1 + src/container-lifecycle.ts | 2 + src/image-resolver.ts | 2 +- src/image-tag.ts | 2 +- src/routing/bootstrap.test.ts | 165 ++++++++ src/routing/bootstrap.ts | 374 ++++++++++++++++++ .../agent-environment-options.test.ts | 16 + .../agent-environment/excluded-vars.ts | 1 + src/services/api-proxy-env-config.test.ts | 43 +- src/services/api-proxy-env-config.ts | 20 +- src/services/api-proxy-service-config.ts | 24 ++ src/services/optional-services.ts | 9 + src/services/router-service.ts | 52 +++ src/types/api-proxy-routing-options.ts | 20 + src/types/index.ts | 1 + 20 files changed, 926 insertions(+), 12 deletions(-) create mode 100644 src/routing/bootstrap.test.ts create mode 100644 src/routing/bootstrap.ts create mode 100644 src/services/router-service.ts diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index e3b5b71dd..59c6fdbab 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -210,6 +210,71 @@ describe('runMainWorkflow', () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it('stages routing before config generation and waits for selection before agent startup', async () => { + const callOrder: string[] = []; + const routingState = { + root: '/tmp/awf-test-routing', + inputDir: '/tmp/awf-test-routing/input', + outputDir: '/tmp/awf-test-routing/output', + inputFile: '/tmp/awf-test-routing/input/conversation.json', + containerInputFile: '/run/awf-routing/input/conversation.json', + containerOutputDir: '/run/awf-routing/output', + }; + const config: WrapperConfig = { + ...baseConfig, + enableApiProxy: true, + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile: '/host/conversation.json' }, + }, + }; + const dependencies = createOrderedWorkflowDependencies(callOrder, 0, { + prepareRouting: jest.fn().mockImplementation(async () => { + callOrder.push('prepareRouting'); + return routingState; + }), + startContainers: jest.fn().mockImplementation(async ( + _workDir, + _allowedDomains, + _proxyLogsDir, + _skipPull, + _onNetworkReady, + onInfrastructureReady, + ) => { + callOrder.push('startContainers'); + await onInfrastructureReady?.(); + }), + waitForRoutingSelection: jest.fn().mockImplementation(async () => { + callOrder.push('waitForRoutingSelection'); + }), + verifyRoutingCompletion: jest.fn().mockImplementation(async () => { + callOrder.push('verifyRoutingCompletion'); + }), + cleanupRouting: jest.fn().mockImplementation(async () => { + callOrder.push('cleanupRouting'); + }), + }); + const { logger, performCleanup } = createOrderedWorkflowOptions(callOrder); + + const exitCode = await runMainWorkflow(config, dependencies, { logger, performCleanup }); + + expect(exitCode).toBe(0); + expect(callOrder).toEqual([ + 'prepareRouting', + 'ensureFirewallNetwork', + 'setupHostIptables', + 'writeConfigs', + 'startContainers', + 'waitForRoutingSelection', + 'runAgentCommand', + 'performCleanup', + 'verifyRoutingCompletion', + 'cleanupRouting', + ]); + expect(dependencies.waitForRoutingSelection).toHaveBeenCalledWith(routingState); + expect(dependencies.verifyRoutingCompletion).toHaveBeenCalledWith(routingState); + }); + it('skips host network setup and iptables in network-isolation mode', async () => { const callOrder: string[] = []; const dependencies = { diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 74541f765..2935fae63 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -7,6 +7,7 @@ import { buildInternalServiceHosts } from './services/internal-service-hosts'; import { TOPOLOGY_NETWORK_NAME, getTopologyContainerIps, patchComposeWithTopologyHosts } from './topology'; import { validateEnclavesConfig } from './enclave/preflight'; import { isEnclaveAgentGithubRouteEnabled } from './types/enclave-options'; +import type { ModelRoutingBootstrapState } from './types'; /** * Dependencies injected into the main workflow. @@ -59,6 +60,14 @@ export interface WorkflowDependencies { * recovery, reconciliation, and the broker admission channel. */ startEnclaveDynamicDelegation?: (config: WrapperConfig) => Promise; + /** Stages the private routing conversation before any container exists. */ + prepareRouting?: (config: WrapperConfig) => Promise; + /** Waits for the proxy-owned routing selection before the primary agent starts. */ + waitForRoutingSelection?: (state: ModelRoutingBootstrapState | undefined) => Promise; + /** Verifies the proxy's end-of-run routing records after sidecar shutdown. */ + verifyRoutingCompletion?: (state: ModelRoutingBootstrapState | undefined) => Promise; + /** Removes private routing state after verification unless containers are kept. */ + cleanupRouting?: (config: WrapperConfig) => Promise; } interface WorkflowCallbacks { @@ -115,6 +124,14 @@ export async function runMainWorkflow( logger.info('Staging enclave repository seeds...'); await dependencies.prepareEnclaves(config); } + let routingState: ModelRoutingBootstrapState | undefined; + if (config.modelRouting) { + if (!dependencies.prepareRouting) { + throw new Error('Model routing is enabled but no staging implementation was provided to runMainWorkflow'); + } + logger.info('Staging model routing conversation...'); + routingState = await dependencies.prepareRouting(config); + } // Step 0: Setup host-level network and iptables // @@ -217,7 +234,7 @@ export async function runMainWorkflow( } : undefined; - const onInfrastructureReady = config.enclaves?.enabled + const enclaveInfrastructureReady = config.enclaves?.enabled ? async () => { if (!dependencies.connectEnclaveGateway || !dependencies.assertEnclaveGatewayReady) { throw new Error('Enclaves require an exclusive MCP gateway readiness implementation'); @@ -252,6 +269,21 @@ export async function runMainWorkflow( } } : undefined; + const routingInfrastructureReady = config.modelRouting + ? async () => { + if (!dependencies.waitForRoutingSelection) { + throw new Error('Model routing is enabled but no selection wait implementation was provided'); + } + logger.info('Waiting for model routing selection...'); + await dependencies.waitForRoutingSelection(routingState); + } + : undefined; + const onInfrastructureReady = enclaveInfrastructureReady || routingInfrastructureReady + ? async () => { + if (enclaveInfrastructureReady) await enclaveInfrastructureReady(); + if (routingInfrastructureReady) await routingInfrastructureReady(); + } + : undefined; try { await dependencies.startContainers( @@ -296,6 +328,13 @@ export async function runMainWorkflow( // Step 4: Cleanup (logs will be preserved automatically if they exist) await performCleanup(); + if (config.modelRouting) { + if (!dependencies.verifyRoutingCompletion) { + throw new Error('Model routing is enabled but no completion verification implementation was provided'); + } + await dependencies.verifyRoutingCompletion(routingState); + await dependencies.cleanupRouting?.(config); + } if (result.exitCode === 0) { logger.success('Command completed successfully'); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 7432f6586..aa6d513ee 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -52,6 +52,13 @@ import { formatCloudHypervisorDockerFallbackWarning, isCloudHypervisorUnsupportedHostError, } from '../cloud-hypervisor/errors'; +import { + cleanupRoutingState, + RoutingFailureExitError, + stageRoutingConversation, + verifyRoutingCompletion, + waitForRoutingSelection, +} from '../routing/bootstrap'; const SENSITIVE_CONFIG_KEYS = new Set([ 'openaiApiKey', @@ -490,6 +497,10 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { assertEnclaveGithubGatewayReady, prepareEnclaves, startEnclaveDynamicDelegation, + prepareRouting: async (routingConfig) => stageRoutingConversation(routingConfig), + waitForRoutingSelection, + verifyRoutingCompletion: async (routingState) => verifyRoutingCompletion(routingState), + cleanupRouting: async (routingConfig) => cleanupRoutingState(routingConfig), }, { logger, @@ -512,8 +523,10 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { writeStartupFailureDiagnostic(config, error); } await performCleanup(); - console.error(`Process exiting with code: 1`); - process.exit(1); + cleanupRoutingState(config); + const fatalExitCode = error instanceof RoutingFailureExitError ? error.exitCode : 1; + console.error(`Process exiting with code: ${fatalExitCode}`); + process.exit(fatalExitCode); } }; } diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 1568f73d1..e7fa698ef 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -40,6 +40,81 @@ describe('generateDockerCompose', () => { expect(result.services.agent.build).toBeUndefined(); }); + it('adds routed proxy wiring without exposing routing state to the agent', () => { + const digest = 'a'.repeat(64); + const routingConfig: WrapperConfig = { + ...mockConfig, + enableApiProxy: true, + images: { + squid: `ghcr.io/example/squid:test@sha256:${digest}`, + agent: `ghcr.io/example/agent:test@sha256:${digest}`, + apiProxy: `ghcr.io/example/api-proxy:test@sha256:${digest}`, + router: `ghcr.io/example/router:test@sha256:${digest}`, + }, + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile: '/run/awf-routing/input/conversation.json' }, + }, + modelRoutingBootstrap: { + root: `${mockConfig.workDir}-routing`, + inputDir: `${mockConfig.workDir}-routing/input`, + outputDir: `${mockConfig.workDir}-routing/output`, + inputFile: `${mockConfig.workDir}-routing/input/conversation.json`, + containerInputFile: '/run/awf-routing/input/conversation.json', + containerOutputDir: '/run/awf-routing/output', + }, + }; + const result = generateDockerCompose(routingConfig, { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }); + const apiProxyService = result.services['api-proxy'] as any; + const agentEnvironment = result.services.agent.environment as Record; + + expect(result.services.router).toBeDefined(); + expect(result.services.router.environment).toBeUndefined(); + expect(result.services.router.ports).toBeUndefined(); + expect(result.services.router.volumes).toBeUndefined(); + expect(result.services.router.networks).toEqual({ + 'awf-routing': { aliases: ['gh-aw-router'] }, + }); + expect(apiProxyService.networks['awf-routing']).toEqual({}); + expect(apiProxyService.depends_on.router).toEqual({ + condition: 'service_healthy', + }); + expect(apiProxyService.volumes).toEqual( + expect.arrayContaining([ + `${mockConfig.workDir}-routing/input:/run/awf-routing/input:ro`, + `${mockConfig.workDir}-routing/output:/run/awf-routing/output:rw`, + ]), + ); + expect(agentEnvironment.AWF_ROUTING_CONFIG).toBeUndefined(); + expect(result.networks['awf-routing']).toMatchObject({ internal: true }); + }); + + it('fails routed compose generation when host staging has not happened', () => { + const digest = 'a'.repeat(64); + const routingConfig: WrapperConfig = { + ...mockConfig, + enableApiProxy: true, + images: { + squid: `ghcr.io/example/squid:test@sha256:${digest}`, + agent: `ghcr.io/example/agent:test@sha256:${digest}`, + apiProxy: `ghcr.io/example/api-proxy:test@sha256:${digest}`, + router: `ghcr.io/example/router:test@sha256:${digest}`, + }, + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile: '/host/conversation.json' }, + }, + }; + + expect(() => generateDockerCompose(routingConfig, { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + })).toThrow('Model routing was configured but the routing conversation was not staged'); + }); + it('should use local build when buildLocal is true', () => { const localConfig = { ...mockConfig, buildLocal: true }; const result = generateDockerCompose(localConfig, mockNetworkConfig); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 09fe98268..b63dde2c2 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -23,6 +23,7 @@ import { } from './enclave/network'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; import { filterComposeCapDrop } from './capability-filter'; +import { ROUTING_NETWORK_NAME } from './services/router-service'; /** * Generates Docker Compose configuration @@ -223,6 +224,13 @@ export function generateDockerCompose( internal: true, }; } + if (config.modelRoutingBootstrap) { + compose.networks[ROUTING_NETWORK_NAME] = { + name: ROUTING_NETWORK_NAME, + driver: 'bridge', + internal: true, + }; + } return filterComposeCapDrop(compose); } diff --git a/src/constants.ts b/src/constants.ts index abe508483..d9bc1f722 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,7 @@ export const AGENT_CONTAINER_NAME = 'awf-agent'; export const SQUID_CONTAINER_NAME = 'awf-squid'; export const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; export const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; +export const ROUTER_CONTAINER_NAME = 'gh-aw-router'; export const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; export const ENCLAVE_MCP_SERVER_CONTAINER_NAME = 'awf-enclave-mcp-server'; diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index 4ccd8c2f2..96d80e3f5 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -6,6 +6,7 @@ import { SQUID_CONTAINER_NAME, IPTABLES_INIT_CONTAINER_NAME, API_PROXY_CONTAINER_NAME, + ROUTER_CONTAINER_NAME, CLI_PROXY_CONTAINER_NAME, ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME, ENCLAVE_MCP_SERVER_CONTAINER_NAME, @@ -308,6 +309,7 @@ export async function startContainers( AGENT_CONTAINER_NAME, IPTABLES_INIT_CONTAINER_NAME, API_PROXY_CONTAINER_NAME, + ROUTER_CONTAINER_NAME, CLI_PROXY_CONTAINER_NAME, ENCLAVE_MCP_SERVER_CONTAINER_NAME, ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME, diff --git a/src/image-resolver.ts b/src/image-resolver.ts index cd03e76ae..0e80fc80b 100644 --- a/src/image-resolver.ts +++ b/src/image-resolver.ts @@ -66,7 +66,7 @@ const DIGEST_PINNED_IMAGE_PATTERN = const DIGEST_PINNED_IMAGE = new RegExp(DIGEST_PINNED_IMAGE_PATTERN); -function isDigestPinnedImageReference(reference: string): boolean { +export function isDigestPinnedImageReference(reference: string): boolean { if (/\s|\$|\{\{/.test(reference)) return false; return DIGEST_PINNED_IMAGE.test(reference); } diff --git a/src/image-tag.ts b/src/image-tag.ts index caa39b032..87c628796 100644 --- a/src/image-tag.ts +++ b/src/image-tag.ts @@ -1,6 +1,6 @@ import path from 'path'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'enclave-script', 'enclave-agent', 'enclave-mcp-server'] as const; +const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'router', 'cli-proxy', 'build-tools', 'enclave-script', 'enclave-agent', 'enclave-mcp-server'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/routing/bootstrap.test.ts b/src/routing/bootstrap.test.ts new file mode 100644 index 000000000..d6527f8a9 --- /dev/null +++ b/src/routing/bootstrap.test.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { WrapperConfig } from '../types'; +import { + cleanupRoutingState, + routingBootstrapTestHelpers, + RoutingFailureExitError, + stageRoutingConversation, + verifyRoutingCompletion, + waitForRoutingSelection, +} from './bootstrap'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); + +const digest = 'a'.repeat(64); + +function makeConfig(workDir: string, conversationFile: string): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'echo ok', + logLevel: 'info', + keepContainers: false, + workDir, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + enableApiProxy: true, + images: { + router: `ghcr.io/example/router:test@sha256:${digest}`, + }, + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile }, + }, + }; +} + +function writeConversation(filename: string): void { + fs.writeFileSync(filename, JSON.stringify([ + { role: 'user', parts: [{ text: 'please fix the tests' }] }, + ])); +} + +describe('routing bootstrap', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-routing-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('validates and stages one private conversation copy with rewritten config', () => { + const conversationFile = path.join(tempDir, 'conversation.json'); + writeConversation(conversationFile); + const config = makeConfig(path.join(tempDir, 'work'), conversationFile); + + const state = stageRoutingConversation(config)!; + + expect(state.inputFile).toBe(path.join(state.inputDir, 'conversation.json')); + expect(config.modelRouting?.task.conversationFile).toBe('/run/awf-routing/input/conversation.json'); + expect(config.modelRoutingBootstrap).toBe(state); + expect(JSON.parse(fs.readFileSync(state.inputFile, 'utf8'))).toEqual([ + { role: 'user', parts: [{ text: 'please fix the tests' }] }, + ]); + expect(fs.statSync(state.inputFile).mode & 0o777).toBe(0o600); + expect(state.root.startsWith(os.homedir())).toBe(true); + + cleanupRoutingState(config); + expect(fs.existsSync(state.root)).toBe(false); + }); + + it('refuses oversized and invalid conversations with public messages', () => { + const conversationFile = path.join(tempDir, 'conversation.json'); + fs.writeFileSync(conversationFile, '{'); + const config = makeConfig(path.join(tempDir, 'work'), conversationFile); + + expect(() => stageRoutingConversation(config)).toThrow('The routing conversation is invalid'); + + fs.writeFileSync(conversationFile, Buffer.alloc(1_048_577, 65)); + expect(() => stageRoutingConversation(config)).toThrow('The routing conversation exceeds 1048576 bytes'); + }); + + it('refuses an unpinned default router image before staging', () => { + const conversationFile = path.join(tempDir, 'conversation.json'); + writeConversation(conversationFile); + const config = makeConfig(path.join(tempDir, 'work'), conversationFile); + delete config.images; + + expect(() => stageRoutingConversation(config)).toThrow( + 'Model routing requires container.images.router to be pinned by digest', + ); + expect(config.modelRoutingBootstrap).toBeUndefined(); + }); + + it('waits for a valid selection and reports routing failures with exit 78', async () => { + const outputDir = path.join(tempDir, 'output'); + fs.mkdirSync(outputDir); + const state = { + root: tempDir, + inputDir: path.join(tempDir, 'input'), + outputDir, + inputFile: path.join(tempDir, 'input/conversation.json'), + containerInputFile: '/run/awf-routing/input/conversation.json', + containerOutputDir: '/run/awf-routing/output', + }; + fs.writeFileSync(path.join(outputDir, 'selection.json'), JSON.stringify({ + schema: 'awf-routing-selection/v1', + engine: 'copilot', + provider: 'copilot', + choice: { id: 'one', model: 'copilot/gpt-5', effort: 'medium' }, + wire_model: 'gpt-5', + })); + + await expect(waitForRoutingSelection(state, 1)).resolves.toBeUndefined(); + + fs.unlinkSync(path.join(outputDir, 'selection.json')); + fs.writeFileSync(path.join(outputDir, 'failure.json'), JSON.stringify({ + schema: 'awf-routing-failure/v1', + code: 'no_route', + detail: 'No eligible route', + retryable: false, + })); + + await expect(waitForRoutingSelection(state, 1)).rejects.toMatchObject({ + exitCode: 78, + message: 'Model routing failed (no_route): No eligible route', + }); + }); + + it('requires selection and completion records for a clean routed run', () => { + const outputDir = path.join(tempDir, 'output'); + fs.mkdirSync(outputDir); + const state = { + root: tempDir, + inputDir: path.join(tempDir, 'input'), + outputDir, + inputFile: path.join(tempDir, 'input/conversation.json'), + containerInputFile: '/run/awf-routing/input/conversation.json', + containerOutputDir: '/run/awf-routing/output', + }; + fs.writeFileSync(path.join(outputDir, 'selection.json'), JSON.stringify({ + schema: 'awf-routing-selection/v1', + engine: 'copilot', + provider: 'copilot', + choice: { id: 'one', model: 'copilot/gpt-5', effort: 'medium' }, + wire_model: 'gpt-5', + })); + + expect(() => verifyRoutingCompletion(state)).toThrow(RoutingFailureExitError); + fs.writeFileSync(path.join(outputDir, 'complete.json'), JSON.stringify({ + schema: 'awf-routing-complete/v1', + })); + expect(() => verifyRoutingCompletion(state)).not.toThrow(); + }); + + it('keeps result-file validation helpers closed to malformed records', () => { + expect(routingBootstrapTestHelpers.isSelectionRecord({ schema: 'awf-routing-selection/v1' })).toBe(false); + expect(routingBootstrapTestHelpers.isFailureRecord({ schema: 'awf-routing-failure/v1' })).toBe(false); + }); +}); diff --git a/src/routing/bootstrap.ts b/src/routing/bootstrap.ts new file mode 100644 index 000000000..e964eaa95 --- /dev/null +++ b/src/routing/bootstrap.ts @@ -0,0 +1,374 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { TextDecoder } from 'util'; +import type { WrapperConfig, ModelRoutingBootstrapState } from '../types'; +import { getRealUserHome, getSafeHostGid, getSafeHostUid } from '../host-identity'; +import { isDigestPinnedImageReference, resolveRuntimeImageFor } from '../image-resolver'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { findDockerSocketExposingMount } from '../enclave/mount-policy'; +import { applyHostPathPrefixToVolumes } from '../services/host-path-prefix'; + +// Import the proxy-owned validator through its declaration file so the host and +// proxy keep one contract for the staged planning conversation. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { validateConversation } = require('../../containers/api-proxy/routing-contract') as typeof import('../../containers/api-proxy/routing-contract'); + +export const ROUTING_CONTAINER_INPUT_DIR = '/run/awf-routing/input'; +export const ROUTING_CONTAINER_OUTPUT_DIR = '/run/awf-routing/output'; +export const ROUTING_CONTAINER_CONVERSATION_FILE = `${ROUTING_CONTAINER_INPUT_DIR}/conversation.json`; +export const ROUTING_SELECTION_TIMEOUT_MS = 90_000; +const MAX_CONVERSATION_BYTES = 1_048_576; +const MAX_RESULT_BYTES = 16_384; +const POLL_INTERVAL_MS = 100; + +export class RoutingFailureExitError extends Error { + readonly exitCode = 78; + + constructor(message: string) { + super(message); + this.name = 'RoutingFailureExitError'; + } +} + +interface RoutingFailureRecord { + schema: 'awf-routing-failure/v1'; + code: string; + detail: string; + retryable: boolean; +} + +function toRoutingFailureExit(message: string): RoutingFailureExitError { + return new RoutingFailureExitError(message); +} + +function routingRootForWorkDir(workDir: string): string { + return path.join(getRealUserHome(), `.awf-routing-${path.basename(workDir)}`); +} + +function parseMountSource(volume: string): string | undefined { + const source = volume.split(':', 1)[0]; + return source && path.isAbsolute(source) ? source : undefined; +} + +function normalizeForOverlap(candidate: string): string { + const missing: string[] = []; + let current = path.resolve(candidate); + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) break; + missing.unshift(path.basename(current)); + current = parent; + } + const resolved = fs.realpathSync.native(current); + return path.resolve(resolved, ...missing); +} + +function pathsOverlap(left: string, right: string): boolean { + const leftToRight = path.relative(left, right); + const rightToLeft = path.relative(right, left); + return leftToRight === '' || rightToLeft === '' || + (!leftToRight.startsWith('..') && !path.isAbsolute(leftToRight)) || + (!rightToLeft.startsWith('..') && !path.isAbsolute(rightToLeft)); +} + +function assertRoutingRootDoesNotOverlapAgentMounts(config: WrapperConfig, root: string): void { + const resolvedRoot = normalizeForOverlap(root); + const translatedRootMount = applyHostPathPrefixToVolumes( + [`${root}:/awf-routing-private:ro`], + config.dockerHostPathPrefix, + )[0]; + const daemonRoot = normalizeForOverlap(parseMountSource(translatedRootMount) ?? root); + const visibleSources = [ + config.workDir, + `${config.workDir}-chroot-home`, + process.env.GITHUB_WORKSPACE || process.cwd(), + '/tmp', + ...(config.sessionStateDir ? [config.sessionStateDir] : []), + ...(config.volumeMounts ?? []).map((volume) => parseMountSource(volume)).filter((value): value is string => !!value), + ]; + + for (const visible of visibleSources) { + const resolvedVisible = normalizeForOverlap(visible); + const translatedVisibleMount = applyHostPathPrefixToVolumes( + [`${visible}:/awf-visible:ro`], + config.dockerHostPathPrefix, + )[0]; + const daemonVisible = normalizeForOverlap(parseMountSource(translatedVisibleMount) ?? visible); + if (pathsOverlap(resolvedRoot, resolvedVisible) || pathsOverlap(daemonRoot, daemonVisible)) { + throw new Error('Model routing private state overlaps an agent-visible path'); + } + } +} + +function assertRoutingHostSupported(config: WrapperConfig): void { + if (process.platform !== 'linux') { + throw new Error('Model routing requires a Linux host'); + } + if (!config.enableApiProxy) { + throw new Error('Model routing requires apiProxy.enabled'); + } + if (config.enableDind || config.dind?.preStageDirs || config.dind?.stageEngineBinary) { + throw new Error('Model routing is not supported with Docker-in-Docker'); + } + if (config.dockerHostPathPrefix) { + throw new Error('Model routing is not supported with split runner/Docker filesystems'); + } + if (!runtimeUsesComposeAgent(config.containerRuntime)) { + throw new Error('Model routing requires a Docker Compose managed agent'); + } + const runtime = config.containerRuntime?.trim(); + if (runtime && runtime !== 'runc') { + throw new Error('Model routing requires the default runc container runtime'); + } + const exposingMount = findDockerSocketExposingMount(config); + if (exposingMount) { + throw new Error('Model routing is not supported when the agent can access the Docker socket'); + } + const routerImage = resolveRuntimeImageFor(config, 'router'); + if (!isDigestPinnedImageReference(routerImage)) { + throw new Error('Model routing requires container.images.router to be pinned by digest'); + } +} + +function readPrivateConversation(source: string): unknown { + let descriptor: number; + try { + descriptor = fs.openSync( + source, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0), + ); + } catch { + throw new Error('The routing conversation is unavailable'); + } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1) { + throw new Error('The routing conversation must be a regular file'); + } + if (stat.size > MAX_CONVERSATION_BYTES) { + throw new Error('The routing conversation exceeds 1048576 bytes'); + } + const buffer = Buffer.alloc(stat.size); + let offset = 0; + while (offset < buffer.length) { + const count = fs.readSync(descriptor, buffer, offset, buffer.length - offset, null); + if (count === 0) break; + offset += count; + } + if (offset !== buffer.length) { + throw new Error('The routing conversation is unavailable'); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer)); + validateConversation(parsed); + } catch { + throw new Error('The routing conversation is invalid'); + } + return parsed; + } finally { + fs.closeSync(descriptor); + } +} + +function ensurePrivateDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); +} + +function chownToSafeIdentity(target: string): void { + const uid = Number.parseInt(getSafeHostUid(), 10); + const gid = Number.parseInt(getSafeHostGid(), 10); + if (!Number.isInteger(uid) || !Number.isInteger(gid)) return; + try { + fs.chownSync(target, uid, gid); + } catch { + // Ownership repair is best-effort for non-root local runs. The proxy uses + // the same safe identity, so directories created by that user already work. + } +} + +function writeJsonNoFollow(filename: string, value: unknown): void { + const content = `${JSON.stringify(value)}\n`; + const descriptor = fs.openSync( + filename, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + try { + fs.writeFileSync(descriptor, content, 'utf8'); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +export function stageRoutingConversation(config: WrapperConfig): ModelRoutingBootstrapState | undefined { + if (!config.modelRouting) return undefined; + + assertRoutingHostSupported(config); + const root = routingRootForWorkDir(config.workDir); + assertRoutingRootDoesNotOverlapAgentMounts(config, root); + + const conversation = readPrivateConversation(config.modelRouting.task.conversationFile); + const inputDir = path.join(root, 'input'); + const outputDir = path.join(root, 'output'); + fs.rmSync(root, { recursive: true, force: true }); + ensurePrivateDir(root); + ensurePrivateDir(inputDir); + ensurePrivateDir(outputDir); + for (const dir of [root, inputDir, outputDir]) chownToSafeIdentity(dir); + + const inputFile = path.join(inputDir, 'conversation.json'); + try { + writeJsonNoFollow(inputFile, conversation); + chownToSafeIdentity(inputFile); + } catch (error) { + fs.rmSync(root, { recursive: true, force: true }); + throw error; + } + + const state = Object.freeze({ + root, + inputDir, + outputDir, + inputFile, + containerInputFile: ROUTING_CONTAINER_CONVERSATION_FILE, + containerOutputDir: ROUTING_CONTAINER_OUTPUT_DIR, + }); + config.modelRoutingBootstrap = state; + config.modelRouting = { + ...config.modelRouting, + task: { conversationFile: ROUTING_CONTAINER_CONVERSATION_FILE }, + }; + return state; +} + +function readRoutingResultFile(filename: string): unknown | null { + let descriptor: number; + try { + descriptor = fs.openSync( + filename, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw toRoutingFailureExit('Model routing result could not be read'); + } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_RESULT_BYTES) { + throw toRoutingFailureExit('Model routing result is invalid'); + } + const buffer = Buffer.alloc(stat.size); + let offset = 0; + while (offset < buffer.length) { + const count = fs.readSync(descriptor, buffer, offset, buffer.length - offset, null); + if (count === 0) break; + offset += count; + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer)); + } catch (error) { + if (error instanceof RoutingFailureExitError) throw error; + throw toRoutingFailureExit('Model routing result is invalid'); + } finally { + fs.closeSync(descriptor); + } +} + +function isFailureRecord(value: unknown): value is RoutingFailureRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return record.schema === 'awf-routing-failure/v1' && + typeof record.code === 'string' && + typeof record.detail === 'string' && + typeof record.retryable === 'boolean'; +} + +function isSelectionRecord(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const choice = record.choice as Record | undefined; + return record.schema === 'awf-routing-selection/v1' && + record.engine === 'copilot' && + record.provider === 'copilot' && + typeof record.wire_model === 'string' && + !!choice && typeof choice === 'object' && + typeof choice.id === 'string' && + typeof choice.model === 'string'; +} + +function routingFailureMessage(record: RoutingFailureRecord): string { + return `Model routing failed (${record.code}): ${record.detail}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForRoutingSelection( + state: ModelRoutingBootstrapState | undefined, + timeoutMs = ROUTING_SELECTION_TIMEOUT_MS, +): Promise { + if (!state) return; + const deadline = Date.now() + timeoutMs; + for (;;) { + const failure = readRoutingResultFile(path.join(state.outputDir, 'failure.json')); + if (failure !== null) { + if (isFailureRecord(failure)) throw toRoutingFailureExit(routingFailureMessage(failure)); + throw toRoutingFailureExit('Model routing failure result is invalid'); + } + const selection = readRoutingResultFile(path.join(state.outputDir, 'selection.json')); + if (selection !== null) { + if (!isSelectionRecord(selection)) throw toRoutingFailureExit('Model routing selection result is invalid'); + return; + } + if (Date.now() >= deadline) { + throw toRoutingFailureExit('Model routing selection timed out'); + } + await sleep(POLL_INTERVAL_MS); + } +} + +export function verifyRoutingCompletion(state: ModelRoutingBootstrapState | undefined): void { + if (!state) return; + const failure = readRoutingResultFile(path.join(state.outputDir, 'failure.json')); + if (failure !== null) { + if (isFailureRecord(failure)) throw toRoutingFailureExit(routingFailureMessage(failure)); + throw toRoutingFailureExit('Model routing failure result is invalid'); + } + const runtimeFailure = readRoutingResultFile(path.join(state.outputDir, 'runtime-failure.json')); + if (runtimeFailure !== null) { + if (isFailureRecord(runtimeFailure)) throw toRoutingFailureExit(routingFailureMessage(runtimeFailure)); + throw toRoutingFailureExit('Model routing runtime failure result is invalid'); + } + const selection = readRoutingResultFile(path.join(state.outputDir, 'selection.json')); + if (!isSelectionRecord(selection)) { + throw toRoutingFailureExit('Model routing selection result is invalid'); + } + const complete = readRoutingResultFile(path.join(state.outputDir, 'complete.json')); + if (!complete || typeof complete !== 'object' || Array.isArray(complete) || + (complete as Record).schema !== 'awf-routing-complete/v1') { + throw toRoutingFailureExit('Model routing completion result is invalid'); + } +} + +export function cleanupRoutingState(config: WrapperConfig): void { + if (!config.keepContainers && config.modelRoutingBootstrap) { + fs.rmSync(config.modelRoutingBootstrap.root, { recursive: true, force: true }); + } +} + +/** @internal Exposed for focused unit tests. */ +// ts-prune-ignore-next +export const routingBootstrapTestHelpers = { + readPrivateConversation, + readRoutingResultFile, + routingRootForWorkDir, + isSelectionRecord, + isFailureRecord, + pathsOverlap, + normalizeForOverlap, + platform: os.platform, +}; diff --git a/src/services/agent-environment-options.test.ts b/src/services/agent-environment-options.test.ts index 8e8040342..f1a026d94 100644 --- a/src/services/agent-environment-options.test.ts +++ b/src/services/agent-environment-options.test.ts @@ -230,6 +230,22 @@ describe('agent environment: options', () => { } }); + it('should exclude host routing control vars from env-all passthrough', () => { + const original = process.env.AWF_ROUTING_CONFIG; + process.env.AWF_ROUTING_CONFIG = '{"task":{"conversationFile":"/host/forged.json"}}'; + + try { + const configWithEnvAll = { ...mockConfig, envAll: true }; + const result = generateDockerCompose(configWithEnvAll, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.AWF_ROUTING_CONFIG).toBeUndefined(); + } finally { + if (original !== undefined) process.env.AWF_ROUTING_CONFIG = original; + else delete process.env.AWF_ROUTING_CONFIG; + } + }); + it('should skip env vars exceeding MAX_ENV_VALUE_SIZE from env-all passthrough', () => { const largeVarName = 'AWF_TEST_OVERSIZED_VAR'; const saved = process.env[largeVarName]; diff --git a/src/services/agent-environment/excluded-vars.ts b/src/services/agent-environment/excluded-vars.ts index 26245020d..33265579f 100644 --- a/src/services/agent-environment/excluded-vars.ts +++ b/src/services/agent-environment/excluded-vars.ts @@ -30,6 +30,7 @@ export function buildExclusionSet(config: WrapperConfig): Set { 'AWF_ENCLAVE_GITHUB_MCP_AGENT_ID', 'AWF_ENCLAVE_GITHUB_DELEGATION_CONTROL_CAPABILITY', 'AWF_ENCLAVE_GITHUB_DELEGATION_CONTROL_ENDPOINT', + 'AWF_ROUTING_CONFIG', ]); if (config.enableApiProxy) { diff --git a/src/services/api-proxy-env-config.test.ts b/src/services/api-proxy-env-config.test.ts index d547cb1ab..5d076eb25 100644 --- a/src/services/api-proxy-env-config.test.ts +++ b/src/services/api-proxy-env-config.test.ts @@ -422,7 +422,7 @@ describe('buildModelPolicyEnv', () => { expect(env.AWF_MODEL_FALLBACK).toBe('{"enabled":false,"strategy":"middle_power"}'); }); -it('sets AWF_ROUTING_CONFIG when modelRouting is configured', () => { +it('does not set AWF_ROUTING_CONFIG when modelRouting is configured', () => { const env = buildModelPolicyEnv({ ...baseConfig, workDir: '/tmp/awf-test', @@ -431,9 +431,7 @@ it('sets AWF_ROUTING_CONFIG when modelRouting is configured', () => { task: { conversationFile: '/tmp/gh-aw/routing-conversation.json' }, }, }); - expect(env.AWF_ROUTING_CONFIG).toBe( - '{"objective":{"goal":"cost","mode":"balanced"},"task":{"conversationFile":"/tmp/gh-aw/routing-conversation.json"}}' - ); + expect(env.AWF_ROUTING_CONFIG).toBeUndefined(); }); it('sets AWF_ALLOWED_MODELS when allowedModels is non-empty', () => { @@ -441,6 +439,43 @@ it('sets AWF_ROUTING_CONFIG when modelRouting is configured', () => { expect(env.AWF_ALLOWED_MODELS).toBe('["gpt-4o","claude-3-5-sonnet"]'); }); + describe('buildModelRoutingEnv', () => { + const { buildModelRoutingEnv } = testHelpers; + + it('rewrites AWF_ROUTING_CONFIG to the staged container input path', () => { + const env = buildModelRoutingEnv({ + ...baseConfig, + workDir: '/tmp/awf-test', + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile: '/host/conversation.json' }, + }, + modelRoutingBootstrap: { + root: '/tmp/awf-test-routing', + inputDir: '/tmp/awf-test-routing/input', + outputDir: '/tmp/awf-test-routing/output', + inputFile: '/tmp/awf-test-routing/input/conversation.json', + containerInputFile: '/run/awf-routing/input/conversation.json', + containerOutputDir: '/run/awf-routing/output', + }, + }); + expect(env.AWF_ROUTING_CONFIG).toBe( + '{"objective":{"goal":"cost","mode":"balanced"},"task":{"conversationFile":"/run/awf-routing/input/conversation.json"}}' + ); + }); + + it('requires host staging before routing env generation', () => { + expect(() => buildModelRoutingEnv({ + ...baseConfig, + workDir: '/tmp/awf-test', + modelRouting: { + objective: { goal: 'cost', mode: 'balanced' }, + task: { conversationFile: '/host/conversation.json' }, + }, + })).toThrow('Model routing was configured but the routing conversation was not staged'); + }); + }); + it('omits AWF_ALLOWED_MODELS when allowedModels is empty', () => { const env = buildModelPolicyEnv({ ...baseConfig, workDir: '/tmp/awf-test', allowedModels: [] }); expect(env.AWF_ALLOWED_MODELS).toBeUndefined(); diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index c6830b686..770a24946 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -272,9 +272,6 @@ function buildModelPolicyEnv(config: WrapperConfig): Record { ...(config.modelFallback && { AWF_MODEL_FALLBACK: JSON.stringify(config.modelFallback), }), - ...(config.modelRouting && { -AWF_ROUTING_CONFIG: JSON.stringify(config.modelRouting), - }), // Model policy (allowed/disallowed) ...(config.allowedModels && config.allowedModels.length > 0 && { AWF_ALLOWED_MODELS: JSON.stringify(config.allowedModels), @@ -311,6 +308,21 @@ AWF_ROUTING_CONFIG: JSON.stringify(config.modelRouting), }; } +function buildModelRoutingEnv(config: WrapperConfig): Record { + if (!config.modelRouting) return {}; + if (!config.modelRoutingBootstrap) { + throw new Error('Model routing was configured but the routing conversation was not staged'); + } + return { + AWF_ROUTING_CONFIG: JSON.stringify({ + ...config.modelRouting, + task: { + conversationFile: config.modelRoutingBootstrap.containerInputFile, + }, + }), + }; +} + /** * Builds OIDC authentication environment variables: Azure/AWS/GCP/Anthropic OIDC provider vars, * GitHub Actions OIDC runtime tokens, and custom auth headers for internal AI gateways. @@ -371,6 +383,7 @@ export function buildApiProxyBaseEnv(config: WrapperConfig, networkConfig: Netwo ...buildOtelEnv(), ...buildRateLimitEnv(config), ...buildModelPolicyEnv(config), + ...buildModelRoutingEnv(config), ...buildOidcEnv(config), }; } @@ -384,6 +397,7 @@ export const testHelpers = { buildOtelEnv, buildRateLimitEnv, buildModelPolicyEnv, + buildModelRoutingEnv, buildOidcEnv, resolveApiProxyShutdownTimeoutMs, }; diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index b0f95cedd..fa5d1611d 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -15,6 +15,11 @@ import { resolveApiProxyShutdownTimeoutMs, } from './api-proxy-env-config'; import { buildApiProxyLifecycleConfig } from './api-proxy-lifecycle-config'; +import { + ROUTING_CONTAINER_INPUT_DIR, + ROUTING_CONTAINER_OUTPUT_DIR, +} from '../routing/bootstrap'; +import { ROUTER_SERVICE_NAME, ROUTING_NETWORK_NAME } from './router-service'; interface ApiProxyServiceConfigParams { config: WrapperConfig; @@ -52,6 +57,9 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): const apiProxyCaCertPath = config.apiProxyCaCert === undefined ? undefined : resolveApiProxyCaCertPath(config.apiProxyCaCert); + if (config.modelRouting && !config.modelRoutingBootstrap) { + throw new Error('Model routing was configured but the routing conversation was not staged'); + } const proxyService: any = { container_name: API_PROXY_CONTAINER_NAME, @@ -62,6 +70,10 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): // Mount log directory for api-proxy logs `${apiProxyLogsPath}:/var/log/api-proxy:rw`, ...(apiProxyCaCertPath ? [`${apiProxyCaCertPath}:${API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH}:ro`] : []), + ...(config.modelRoutingBootstrap ? [ + `${config.modelRoutingBootstrap.inputDir}:${ROUTING_CONTAINER_INPUT_DIR}:ro`, + `${config.modelRoutingBootstrap.outputDir}:${ROUTING_CONTAINER_OUTPUT_DIR}:rw`, + ] : []), ], config.dockerHostPathPrefix, ), @@ -70,6 +82,18 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): ...buildContainerSecurityHardening({ memLimit: '512m', pidsLimit: 100, cpuShares: 512 }), stop_grace_period: `${stopGracePeriodSeconds}s`, }; + if (config.modelRoutingBootstrap) { + proxyService.networks = { + ...proxyService.networks, + [ROUTING_NETWORK_NAME]: {}, + }; + proxyService.depends_on = { + ...(proxyService.depends_on || {}), + [ROUTER_SERVICE_NAME]: { + condition: 'service_healthy', + }, + }; + } // Use GHCR image or build locally assignImageSource(proxyService, { diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 682705336..901623996 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -6,6 +6,7 @@ import { buildApiProxyService } from './api-proxy-service'; import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; import { buildEnclaveMcpService } from './enclave-mcp-service'; +import { buildRouterService } from './router-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; @@ -315,6 +316,12 @@ function assembleApiProxyService(params: AssembleOptionalServicesParams): void { }; } +function assembleRouterService(params: AssembleOptionalServicesParams): void { + const { services, config, imageConfig } = params; + if (!config.modelRoutingBootstrap) return; + services['router'] = buildRouterService({ imageConfig }); +} + function assembleDohProxyService(params: AssembleOptionalServicesParams): void { const { services, agentService, config, networkConfig } = params; @@ -407,6 +414,7 @@ export function assembleOptionalServices( presetSidecarIpEnvVars(environment, config, networkConfig); assembleEnclaveMcpService(params); + assembleRouterService(params); if (includeComposeAgent) { assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); assembleIptablesInitService(params, skipIptables); @@ -426,4 +434,5 @@ export function assembleOptionalServices( export const testHelpers = { presetSidecarIpEnvVars, filterAgentVolumesForSysroot, + assembleRouterService, }; diff --git a/src/services/router-service.ts b/src/services/router-service.ts new file mode 100644 index 000000000..0a3c20803 --- /dev/null +++ b/src/services/router-service.ts @@ -0,0 +1,52 @@ +import { ROUTER_CONTAINER_NAME } from '../constants'; +import { assignImageSource } from '../image-tag'; +import { buildContainerSecurityHardening } from './service-security'; +import { ImageBuildConfig } from './squid-service'; + +export const ROUTING_NETWORK_NAME = 'awf-routing'; +export const ROUTER_SERVICE_NAME = 'router'; +export const ROUTER_DNS_NAME = 'gh-aw-router'; +const ROUTER_PORT = 8737; + +interface RouterServiceParams { + imageConfig: ImageBuildConfig; +} + +export function buildRouterService({ imageConfig }: RouterServiceParams): any { + const { useGHCR, registry, parsedTag, projectRoot, resolveImage } = imageConfig; + const service: any = { + container_name: ROUTER_CONTAINER_NAME, + networks: { + [ROUTING_NETWORK_NAME]: { + aliases: [ROUTER_DNS_NAME], + }, + }, + healthcheck: { + test: ['CMD', 'curl', '-fsS', `http://localhost:${ROUTER_PORT}/healthz`], + interval: '2s', + timeout: '3s', + retries: 15, + start_period: '10s', + }, + ...buildContainerSecurityHardening({ memLimit: '512m', pidsLimit: 100, cpuShares: 512 }), + stop_grace_period: '2s', + }; + + assignImageSource(service, { + useGHCR, + registry, + imageName: 'router', + parsedTag, + projectRoot, + containerDir: 'router', + }); + if (useGHCR && resolveImage) service.image = resolveImage('router'); + + // The router is deliberately credential-free. Keep the fields absent rather + // than empty so Compose cannot inherit or publish anything implicitly. + delete service.environment; + delete service.ports; + delete service.volumes; + + return service; +} diff --git a/src/types/api-proxy-routing-options.ts b/src/types/api-proxy-routing-options.ts index 760529a98..bf16f9ddd 100644 --- a/src/types/api-proxy-routing-options.ts +++ b/src/types/api-proxy-routing-options.ts @@ -15,6 +15,15 @@ export interface ModelRoutingConfig { }; } +export interface ModelRoutingBootstrapState { + root: string; + inputDir: string; + outputDir: string; + inputFile: string; + containerInputFile: string; + containerOutputDir: string; +} + export interface ApiProxyRoutingOptions { /** * Optional task-level model routing configuration. @@ -24,6 +33,17 @@ export interface ApiProxyRoutingOptions { */ modelRouting?: ModelRoutingConfig; + /** + * Host-side routing file-channel state created before Compose generation. + * + * This is intentionally not part of the user-facing config surface. It is set + * only by the trusted CLI bootstrap path after validating and staging the + * routing conversation. + * + * @internal + */ + modelRoutingBootstrap?: ModelRoutingBootstrapState; + /** * Host path to an additional CA certificate for api-proxy upstream TLS. * diff --git a/src/types/index.ts b/src/types/index.ts index f95fad03a..ecb9d271f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ export { } from './ports'; export type * from './wrapper-config'; +export type { ModelRoutingBootstrapState } from './api-proxy-routing-options'; export { type UpstreamProxyConfig } from './upstream-proxy'; export { type LogLevel } from './log-level'; From a1046d0c45ab4bd6ead2eb359134e2f872c0b884 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:20:51 +0000 Subject: [PATCH 3/7] fix: address routing host validation feedback --- src/cli-workflow.ts | 7 +++++-- src/constants.ts | 2 +- src/routing/bootstrap.ts | 2 -- src/services/api-proxy-env-config.test.ts | 2 +- src/services/api-proxy-env-config.ts | 3 ++- src/services/api-proxy-service-config.ts | 3 ++- src/services/optional-services.ts | 4 ++-- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 2935fae63..d39431a96 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -332,8 +332,11 @@ export async function runMainWorkflow( if (!dependencies.verifyRoutingCompletion) { throw new Error('Model routing is enabled but no completion verification implementation was provided'); } - await dependencies.verifyRoutingCompletion(routingState); - await dependencies.cleanupRouting?.(config); + try { + await dependencies.verifyRoutingCompletion(routingState); + } finally { + await dependencies.cleanupRouting?.(config); + } } if (result.exitCode === 0) { diff --git a/src/constants.ts b/src/constants.ts index d9bc1f722..25c1ed228 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,7 +7,7 @@ export const AGENT_CONTAINER_NAME = 'awf-agent'; export const SQUID_CONTAINER_NAME = 'awf-squid'; export const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; export const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; -export const ROUTER_CONTAINER_NAME = 'gh-aw-router'; +export const ROUTER_CONTAINER_NAME = 'awf-router'; export const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; export const ENCLAVE_MCP_SERVER_CONTAINER_NAME = 'awf-enclave-mcp-server'; diff --git a/src/routing/bootstrap.ts b/src/routing/bootstrap.ts index e964eaa95..2ee6542c7 100644 --- a/src/routing/bootstrap.ts +++ b/src/routing/bootstrap.ts @@ -1,5 +1,4 @@ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { TextDecoder } from 'util'; import type { WrapperConfig, ModelRoutingBootstrapState } from '../types'; @@ -370,5 +369,4 @@ export const routingBootstrapTestHelpers = { isFailureRecord, pathsOverlap, normalizeForOverlap, - platform: os.platform, }; diff --git a/src/services/api-proxy-env-config.test.ts b/src/services/api-proxy-env-config.test.ts index 5d076eb25..41e068bc1 100644 --- a/src/services/api-proxy-env-config.test.ts +++ b/src/services/api-proxy-env-config.test.ts @@ -422,7 +422,7 @@ describe('buildModelPolicyEnv', () => { expect(env.AWF_MODEL_FALLBACK).toBe('{"enabled":false,"strategy":"middle_power"}'); }); -it('does not set AWF_ROUTING_CONFIG when modelRouting is configured', () => { +it('buildModelPolicyEnv no longer sets AWF_ROUTING_CONFIG when modelRouting is configured', () => { const env = buildModelPolicyEnv({ ...baseConfig, workDir: '/tmp/awf-test', diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index 770a24946..2991cdbb4 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -11,6 +11,7 @@ import { normalizeCodexHostedWebPolicy } from '../codex-hosted-web-policy'; const DEFAULT_API_PROXY_SHUTDOWN_TIMEOUT_MS = 8000; export const API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH = '/usr/local/share/ca-certificates/awf-upstream-ca.crt'; +export const MODEL_ROUTING_NOT_STAGED_MESSAGE = 'Model routing was configured but the routing conversation was not staged'; /** * Builds provider API target/basePath environment variables for the api-proxy container. @@ -311,7 +312,7 @@ function buildModelPolicyEnv(config: WrapperConfig): Record { function buildModelRoutingEnv(config: WrapperConfig): Record { if (!config.modelRouting) return {}; if (!config.modelRoutingBootstrap) { - throw new Error('Model routing was configured but the routing conversation was not staged'); + throw new Error(MODEL_ROUTING_NOT_STAGED_MESSAGE); } return { AWF_ROUTING_CONFIG: JSON.stringify({ diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index fa5d1611d..114047b5d 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -11,6 +11,7 @@ import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; import { API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH, + MODEL_ROUTING_NOT_STAGED_MESSAGE, buildApiProxyBaseEnv, resolveApiProxyShutdownTimeoutMs, } from './api-proxy-env-config'; @@ -58,7 +59,7 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): ? undefined : resolveApiProxyCaCertPath(config.apiProxyCaCert); if (config.modelRouting && !config.modelRoutingBootstrap) { - throw new Error('Model routing was configured but the routing conversation was not staged'); + throw new Error(MODEL_ROUTING_NOT_STAGED_MESSAGE); } const proxyService: any = { diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 901623996..6cdef5a5c 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -6,7 +6,7 @@ import { buildApiProxyService } from './api-proxy-service'; import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; import { buildEnclaveMcpService } from './enclave-mcp-service'; -import { buildRouterService } from './router-service'; +import { buildRouterService, ROUTER_SERVICE_NAME } from './router-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; @@ -319,7 +319,7 @@ function assembleApiProxyService(params: AssembleOptionalServicesParams): void { function assembleRouterService(params: AssembleOptionalServicesParams): void { const { services, config, imageConfig } = params; if (!config.modelRoutingBootstrap) return; - services['router'] = buildRouterService({ imageConfig }); + services[ROUTER_SERVICE_NAME] = buildRouterService({ imageConfig }); } function assembleDohProxyService(params: AssembleOptionalServicesParams): void { From 5f8358f6a3b7e02ed01ca15cc5ec79d4c04ed9b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:26 +0000 Subject: [PATCH 4/7] fix: clean up routing review nits --- src/routing/bootstrap.ts | 34 +++++++++++++----------- src/services/api-proxy-service-config.ts | 4 --- src/services/router-service.ts | 6 ----- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/routing/bootstrap.ts b/src/routing/bootstrap.ts index 2ee6542c7..85993c143 100644 --- a/src/routing/bootstrap.ts +++ b/src/routing/bootstrap.ts @@ -37,6 +37,10 @@ interface RoutingFailureRecord { retryable: boolean; } +type RoutingResultRead = + | { found: false } + | { found: true; value: unknown }; + function toRoutingFailureExit(message: string): RoutingFailureExitError { return new RoutingFailureExitError(message); } @@ -146,7 +150,7 @@ function readPrivateConversation(source: string): unknown { throw new Error('The routing conversation must be a regular file'); } if (stat.size > MAX_CONVERSATION_BYTES) { - throw new Error('The routing conversation exceeds 1048576 bytes'); + throw new Error(`The routing conversation exceeds ${MAX_CONVERSATION_BYTES} bytes`); } const buffer = Buffer.alloc(stat.size); let offset = 0; @@ -244,7 +248,7 @@ export function stageRoutingConversation(config: WrapperConfig): ModelRoutingBoo return state; } -function readRoutingResultFile(filename: string): unknown | null { +function readRoutingResultFile(filename: string): RoutingResultRead { let descriptor: number; try { descriptor = fs.openSync( @@ -252,7 +256,7 @@ function readRoutingResultFile(filename: string): unknown | null { fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0), ); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { found: false }; throw toRoutingFailureExit('Model routing result could not be read'); } try { @@ -267,7 +271,7 @@ function readRoutingResultFile(filename: string): unknown | null { if (count === 0) break; offset += count; } - return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer)); + return { found: true, value: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer)) }; } catch (error) { if (error instanceof RoutingFailureExitError) throw error; throw toRoutingFailureExit('Model routing result is invalid'); @@ -314,13 +318,13 @@ export async function waitForRoutingSelection( const deadline = Date.now() + timeoutMs; for (;;) { const failure = readRoutingResultFile(path.join(state.outputDir, 'failure.json')); - if (failure !== null) { - if (isFailureRecord(failure)) throw toRoutingFailureExit(routingFailureMessage(failure)); + if (failure.found) { + if (isFailureRecord(failure.value)) throw toRoutingFailureExit(routingFailureMessage(failure.value)); throw toRoutingFailureExit('Model routing failure result is invalid'); } const selection = readRoutingResultFile(path.join(state.outputDir, 'selection.json')); - if (selection !== null) { - if (!isSelectionRecord(selection)) throw toRoutingFailureExit('Model routing selection result is invalid'); + if (selection.found) { + if (!isSelectionRecord(selection.value)) throw toRoutingFailureExit('Model routing selection result is invalid'); return; } if (Date.now() >= deadline) { @@ -333,22 +337,22 @@ export async function waitForRoutingSelection( export function verifyRoutingCompletion(state: ModelRoutingBootstrapState | undefined): void { if (!state) return; const failure = readRoutingResultFile(path.join(state.outputDir, 'failure.json')); - if (failure !== null) { - if (isFailureRecord(failure)) throw toRoutingFailureExit(routingFailureMessage(failure)); + if (failure.found) { + if (isFailureRecord(failure.value)) throw toRoutingFailureExit(routingFailureMessage(failure.value)); throw toRoutingFailureExit('Model routing failure result is invalid'); } const runtimeFailure = readRoutingResultFile(path.join(state.outputDir, 'runtime-failure.json')); - if (runtimeFailure !== null) { - if (isFailureRecord(runtimeFailure)) throw toRoutingFailureExit(routingFailureMessage(runtimeFailure)); + if (runtimeFailure.found) { + if (isFailureRecord(runtimeFailure.value)) throw toRoutingFailureExit(routingFailureMessage(runtimeFailure.value)); throw toRoutingFailureExit('Model routing runtime failure result is invalid'); } const selection = readRoutingResultFile(path.join(state.outputDir, 'selection.json')); - if (!isSelectionRecord(selection)) { + if (!selection.found || !isSelectionRecord(selection.value)) { throw toRoutingFailureExit('Model routing selection result is invalid'); } const complete = readRoutingResultFile(path.join(state.outputDir, 'complete.json')); - if (!complete || typeof complete !== 'object' || Array.isArray(complete) || - (complete as Record).schema !== 'awf-routing-complete/v1') { + if (!complete.found || !complete.value || typeof complete.value !== 'object' || Array.isArray(complete.value) || + (complete.value as Record).schema !== 'awf-routing-complete/v1') { throw toRoutingFailureExit('Model routing completion result is invalid'); } } diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index 114047b5d..12808b563 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -11,7 +11,6 @@ import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; import { API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH, - MODEL_ROUTING_NOT_STAGED_MESSAGE, buildApiProxyBaseEnv, resolveApiProxyShutdownTimeoutMs, } from './api-proxy-env-config'; @@ -58,9 +57,6 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): const apiProxyCaCertPath = config.apiProxyCaCert === undefined ? undefined : resolveApiProxyCaCertPath(config.apiProxyCaCert); - if (config.modelRouting && !config.modelRoutingBootstrap) { - throw new Error(MODEL_ROUTING_NOT_STAGED_MESSAGE); - } const proxyService: any = { container_name: API_PROXY_CONTAINER_NAME, diff --git a/src/services/router-service.ts b/src/services/router-service.ts index 0a3c20803..9ead0dd51 100644 --- a/src/services/router-service.ts +++ b/src/services/router-service.ts @@ -42,11 +42,5 @@ export function buildRouterService({ imageConfig }: RouterServiceParams): any { }); if (useGHCR && resolveImage) service.image = resolveImage('router'); - // The router is deliberately credential-free. Keep the fields absent rather - // than empty so Compose cannot inherit or publish anything implicitly. - delete service.environment; - delete service.ports; - delete service.volumes; - return service; } From b058eb6ca1124f7aab3b1012f50216a2dba19416 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:22:21 +0000 Subject: [PATCH 5/7] fix: harden routed workflow cleanup --- src/commands/main-action.test.ts | 15 ++++++ src/commands/main-action.ts | 14 +++++- src/commands/signal-handler.test.ts | 20 +++++++- src/commands/signal-handler.ts | 5 ++ src/compose-generator.test.ts | 6 +++ src/container-lifecycle.ts | 11 ++++- src/container-start.test.ts | 21 +++++++++ src/coverage-branch-gaps-3.test.ts | 2 + src/routing/bootstrap.test.ts | 11 +++++ src/routing/bootstrap.ts | 3 ++ src/services/router-service.ts | 2 +- tests/integration/model-routing.test.ts | 63 +++++++++++++++++++++++++ 12 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tests/integration/model-routing.test.ts diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index b24bfc6e2..bd43a53db 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -46,6 +46,7 @@ import * as externalRuntimeResolver from '../external-runtime-backend-resolver'; import { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils'; import type { WrapperConfig } from '../types'; import { CloudHypervisorUnsupportedHostError } from '../cloud-hypervisor/errors'; +import { RoutingFailureExitError } from '../routing/bootstrap'; const { mkdirSync: mockMkdirSync, @@ -863,6 +864,20 @@ describe('createMainAction', () => { }); describe('fatal error cleanup after containers started', () => { + it('preserves the routing exit code through an infrastructure readiness error', async () => { + const routingFailure = new RoutingFailureExitError('Model routing selection timed out'); + const readinessFailure = Object.assign( + new Error('Model routing selection timed out'), + { cause: routingFailure }, + ); + mockedCliWorkflow.runMainWorkflow.mockRejectedValueOnce(readinessFailure); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(processExitSpy).toHaveBeenCalledWith(78); + }); + it('stops containers during cleanup when workflow fails after startup callbacks', async () => { mockedCliWorkflow.runMainWorkflow.mockImplementation( async (_config, _deps, callbacks) => { diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index aa6d513ee..10efda647 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -74,6 +74,17 @@ const SENSITIVE_CONFIG_KEYS = new Set([ const REFLECT_COMMAND = 'curl --fail --silent --show-error --noproxy "*" http://api-proxy:10000/reflect'; +function findRoutingFailure(error: unknown): RoutingFailureExitError | undefined { + const seen = new Set(); + let current = error; + while (current instanceof Error && !seen.has(current)) { + if (current instanceof RoutingFailureExitError) return current; + seen.add(current); + current = (current as Error & { cause?: unknown }).cause; + } + return undefined; +} + function redactConfigForLogging(config: WrapperConfig): Record { const redactedConfig: Record = {}; for (const [key, value] of Object.entries(config)) { @@ -414,6 +425,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { : fastKillAgentContainer() ), performCleanup: (signal) => performCleanup(signal), + cleanupRouting: () => cleanupRoutingState(config), }); if (externalRuntimeBackend) { @@ -524,7 +536,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } await performCleanup(); cleanupRoutingState(config); - const fatalExitCode = error instanceof RoutingFailureExitError ? error.exitCode : 1; + const fatalExitCode = findRoutingFailure(error)?.exitCode ?? 1; console.error(`Process exiting with code: ${fatalExitCode}`); process.exit(fatalExitCode); } diff --git a/src/commands/signal-handler.test.ts b/src/commands/signal-handler.test.ts index f7e25c9b4..a21c50d3d 100644 --- a/src/commands/signal-handler.test.ts +++ b/src/commands/signal-handler.test.ts @@ -16,24 +16,26 @@ describe('registerSignalHandlers', () => { containersStarted: boolean; keepContainers: boolean; fastKillRejects?: boolean; - }): Promise<{ fastKill: jest.Mock; performCleanup: jest.Mock }> { + }): Promise<{ fastKill: jest.Mock; performCleanup: jest.Mock; cleanupRouting: jest.Mock }> { const fastKill = fastKillRejects ? jest.fn().mockRejectedValue(new Error('kill failed')) : jest.fn().mockResolvedValue(undefined); const performCleanup = jest.fn().mockResolvedValue(undefined); + const cleanupRouting = jest.fn(); const deps: SignalHandlerDependencies = { getContainersStarted: () => containersStarted, keepContainers, fastKillAgentContainer: fastKill, performCleanup, + cleanupRouting, }; registerSignalHandlers(deps); harness.handlers[signal](); await flushPromises(); - return { fastKill, performCleanup }; + return { fastKill, performCleanup, cleanupRouting }; } it('registers SIGINT and SIGTERM handlers', () => { @@ -42,6 +44,7 @@ describe('registerSignalHandlers', () => { keepContainers: false, fastKillAgentContainer: jest.fn().mockResolvedValue(undefined), performCleanup: jest.fn().mockResolvedValue(undefined), + cleanupRouting: jest.fn(), }; registerSignalHandlers(deps); @@ -68,6 +71,19 @@ describe('registerSignalHandlers', () => { } ); + it.each(['SIGINT', 'SIGTERM'] as const)( + 'cleans private routing state on %s', + async signal => { + const { cleanupRouting } = await runSignalScenario({ + signal, + containersStarted: true, + keepContainers: false, + }); + + expect(cleanupRouting).toHaveBeenCalledTimes(1); + }, + ); + it('skips fast-kill on SIGINT when containers are not started', async () => { const { fastKill, performCleanup } = await runSignalScenario({ signal: 'SIGINT', diff --git a/src/commands/signal-handler.ts b/src/commands/signal-handler.ts index 8e69e7eea..de898b4e2 100644 --- a/src/commands/signal-handler.ts +++ b/src/commands/signal-handler.ts @@ -10,6 +10,8 @@ interface SignalHandlerDependencies { fastKillAgentContainer: () => Promise; /** Runs the full cleanup sequence (stop containers, remove host iptables rules, etc.). */ performCleanup: (signal?: string) => Promise; + /** Removes private state created for model routing. */ + cleanupRouting: () => void; } /** @@ -25,6 +27,7 @@ export function registerSignalHandlers({ keepContainers, fastKillAgentContainer, performCleanup, + cleanupRouting, }: SignalHandlerDependencies): void { process.on('SIGINT', () => { (async () => { @@ -34,6 +37,7 @@ export function registerSignalHandlers({ } await performCleanup('SIGINT'); } finally { + cleanupRouting(); console.error(`Process exiting with code: 130`); process.exit(130); // Standard exit code for SIGINT } @@ -48,6 +52,7 @@ export function registerSignalHandlers({ } await performCleanup('SIGTERM'); } finally { + cleanupRouting(); console.error(`Process exiting with code: 143`); process.exit(143); // Standard exit code for SIGTERM } diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index e7fa698ef..cffb9b2f4 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -78,6 +78,12 @@ describe('generateDockerCompose', () => { expect(result.services.router.networks).toEqual({ 'awf-routing': { aliases: ['gh-aw-router'] }, }); + expect(result.services.router.healthcheck?.test).toEqual([ + 'CMD', + 'python', + '-c', + "import urllib.request; urllib.request.urlopen('http://localhost:8737/healthz', timeout=1).close()", + ]); expect(apiProxyService.networks['awf-routing']).toEqual({}); expect(apiProxyService.depends_on.router).toEqual({ condition: 'service_healthy', diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index 96d80e3f5..c9a1e7221 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -30,7 +30,15 @@ const MAX_GVISOR_AGENT_RETRIES = 1; // Node/V8 initialisation (before any agent work began) and are safe to restart. const GVISOR_STARTUP_CRASH_WINDOW_MS = 30_000; -class InfrastructureReadinessError extends Error {} +class InfrastructureReadinessError extends Error { + readonly cause: unknown; + + constructor(message: string, cause: unknown) { + super(message); + this.name = 'InfrastructureReadinessError'; + this.cause = cause; + } +} class PostReadinessAgentStartupError extends Error {} function getComposeUpArgs(skipPull?: boolean): string[] { @@ -110,6 +118,7 @@ async function attemptContainerStartup( } catch (error) { throw new InfrastructureReadinessError( error instanceof Error ? error.message : String(error), + error, ); } if (!services.includes('agent')) { diff --git a/src/container-start.test.ts b/src/container-start.test.ts index f0d6b704e..0a69c1bb4 100644 --- a/src/container-start.test.ts +++ b/src/container-start.test.ts @@ -27,6 +27,7 @@ describe('startContainers', () => { 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', + 'awf-router', 'awf-cli-proxy', 'awf-enclave-mcp-server', 'awf-enclave-agent-api-proxy', @@ -644,6 +645,26 @@ describe('startContainers', () => { expect(fullAgentStart).toBe(false); }); + it('preserves the readiness failure as the cause', async () => { + const readinessFailure = new Error('routing selection timed out'); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + mockExecaFn.mockResolvedValueOnce({ + stdout: 'squid-proxy\napi-proxy\nrouter\nagent\n', + stderr: '', + exitCode: 0, + } as any); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await expect(startContainers( + getDir(), + ['github.com'], + undefined, + undefined, + undefined, + jest.fn().mockRejectedValue(readinessFailure), + )).rejects.toMatchObject({ cause: readinessFailure }); + }); + it('runs the same readiness gate for an sbx infrastructure-only compose file', async () => { const onInfrastructureReady = jest.fn().mockResolvedValue(undefined); mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); diff --git a/src/coverage-branch-gaps-3.test.ts b/src/coverage-branch-gaps-3.test.ts index 7d0c0c44c..5c9400aa0 100644 --- a/src/coverage-branch-gaps-3.test.ts +++ b/src/coverage-branch-gaps-3.test.ts @@ -28,6 +28,7 @@ describe('registerSignalHandlers — SIGTERM keepContainers=true (line 46 false keepContainers: true, fastKillAgentContainer: fastKill, performCleanup, + cleanupRouting: jest.fn(), }); harness.handlers['SIGTERM'](); @@ -47,6 +48,7 @@ describe('registerSignalHandlers — SIGTERM keepContainers=true (line 46 false keepContainers: false, fastKillAgentContainer: fastKill, performCleanup, + cleanupRouting: jest.fn(), }); harness.handlers['SIGTERM'](); diff --git a/src/routing/bootstrap.test.ts b/src/routing/bootstrap.test.ts index d6527f8a9..2b47ea19a 100644 --- a/src/routing/bootstrap.test.ts +++ b/src/routing/bootstrap.test.ts @@ -97,6 +97,17 @@ describe('routing bootstrap', () => { expect(config.modelRoutingBootstrap).toBeUndefined(); }); + it('refuses routing with preserved containers before staging', () => { + const conversationFile = path.join(tempDir, 'conversation.json'); + writeConversation(conversationFile); + const config = { ...makeConfig(path.join(tempDir, 'work'), conversationFile), keepContainers: true }; + + expect(() => stageRoutingConversation(config)).toThrow( + 'Model routing is not supported with --keep-containers', + ); + expect(config.modelRoutingBootstrap).toBeUndefined(); + }); + it('waits for a valid selection and reports routing failures with exit 78', async () => { const outputDir = path.join(tempDir, 'output'); fs.mkdirSync(outputDir); diff --git a/src/routing/bootstrap.ts b/src/routing/bootstrap.ts index 85993c143..7ab24216b 100644 --- a/src/routing/bootstrap.ts +++ b/src/routing/bootstrap.ts @@ -111,6 +111,9 @@ function assertRoutingHostSupported(config: WrapperConfig): void { if (!config.enableApiProxy) { throw new Error('Model routing requires apiProxy.enabled'); } + if (config.keepContainers) { + throw new Error('Model routing is not supported with --keep-containers'); + } if (config.enableDind || config.dind?.preStageDirs || config.dind?.stageEngineBinary) { throw new Error('Model routing is not supported with Docker-in-Docker'); } diff --git a/src/services/router-service.ts b/src/services/router-service.ts index 9ead0dd51..78113641f 100644 --- a/src/services/router-service.ts +++ b/src/services/router-service.ts @@ -22,7 +22,7 @@ export function buildRouterService({ imageConfig }: RouterServiceParams): any { }, }, healthcheck: { - test: ['CMD', 'curl', '-fsS', `http://localhost:${ROUTER_PORT}/healthz`], + test: ['CMD', 'python', '-c', `import urllib.request; urllib.request.urlopen('http://localhost:${ROUTER_PORT}/healthz', timeout=1).close()`], interval: '2s', timeout: '3s', retries: 15, diff --git a/tests/integration/model-routing.test.ts b/tests/integration/model-routing.test.ts new file mode 100644 index 000000000..5f1e9855f --- /dev/null +++ b/tests/integration/model-routing.test.ts @@ -0,0 +1,63 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import execa = require('execa'); +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import { cleanup } from '../fixtures/cleanup'; + +const ROUTER_IMAGE = 'ghcr.io/githubnext/gh-aw-router:latest@sha256:d1612d0eaec3fa8f14c38bbd0a6a0682732fc9f83b7fec94219d3e757a048270'; +const API_PROXY_IMAGE = 'ghcr.io/github/gh-aw-firewall/api-proxy:latest@sha256:9569f2c75545c4af0583b433fa5e3c477089fd1300b109d2b2358f4aa9702c6b'; + +describe('Model routing', () => { + let tempDir: string; + let composeFile: string; + + beforeAll(async () => { + await cleanup(false); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-routing-integration-')); + composeFile = path.join(tempDir, 'docker-compose.yml'); + fs.writeFileSync(composeFile, [ + 'services:', + ' router:', + ` image: ${ROUTER_IMAGE}`, + ' networks:', + ' routing:', + ' aliases: [gh-aw-router]', + ' healthcheck:', + ` test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8737/healthz', timeout=1).close()"]`, + ' interval: 2s', + ' timeout: 3s', + ' retries: 15', + ' api-proxy:', + ` image: ${API_PROXY_IMAGE}`, + ' networks: [routing, egress]', + ' depends_on:', + ' router:', + ' condition: service_healthy', + 'networks:', + ' routing:', + ' internal: true', + ' egress: {}', + '', + ].join('\n'), { mode: 0o600 }); + }); + + afterAll(async () => { + await execa('docker', ['compose', '-f', composeFile, 'down', '-v'], { reject: false }); + fs.rmSync(tempDir, { recursive: true, force: true }); + await cleanup(false); + }); + + test('starts the pinned router and reaches it from the API proxy', async () => { + await execa('docker', ['compose', '-f', composeFile, 'up', '-d']); + const result = await execa('docker', [ + 'compose', '-f', composeFile, 'exec', '-T', 'api-proxy', + 'node', '-e', + "let attempts = 0; const probe = () => require('http').get('http://gh-aw-router:8737/healthz', response => process.exit(response.statusCode === 204 ? 0 : 1)).on('error', () => ++attempts < 3 ? setTimeout(probe, 200) : process.exit(1)); probe()", + ], { + reject: false, + }); + + expect(result.exitCode).toBe(0); + }, 360000); +}); From a0cde9733092de4aaf7055fdde366599ff77787e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:25:32 +0000 Subject: [PATCH 6/7] fix: align router health validation --- src/container-lifecycle.ts | 4 +--- tests/integration/model-routing.test.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index c9a1e7221..2186a0311 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -31,12 +31,10 @@ const MAX_GVISOR_AGENT_RETRIES = 1; const GVISOR_STARTUP_CRASH_WINDOW_MS = 30_000; class InfrastructureReadinessError extends Error { - readonly cause: unknown; - constructor(message: string, cause: unknown) { super(message); this.name = 'InfrastructureReadinessError'; - this.cause = cause; + Object.defineProperty(this, 'cause', { value: cause, configurable: true }); } } class PostReadinessAgentStartupError extends Error {} diff --git a/tests/integration/model-routing.test.ts b/tests/integration/model-routing.test.ts index 5f1e9855f..7e0f78759 100644 --- a/tests/integration/model-routing.test.ts +++ b/tests/integration/model-routing.test.ts @@ -53,7 +53,7 @@ describe('Model routing', () => { const result = await execa('docker', [ 'compose', '-f', composeFile, 'exec', '-T', 'api-proxy', 'node', '-e', - "let attempts = 0; const probe = () => require('http').get('http://gh-aw-router:8737/healthz', response => process.exit(response.statusCode === 204 ? 0 : 1)).on('error', () => ++attempts < 3 ? setTimeout(probe, 200) : process.exit(1)); probe()", + "let attempts = 0; const probe = () => require('http').get('http://gh-aw-router:8737/healthz', response => process.exit(response.statusCode >= 200 && response.statusCode < 300 ? 0 : 1)).on('error', () => ++attempts < 3 ? setTimeout(probe, 200) : process.exit(1)); probe()", ], { reject: false, }); From 98ccddc36b7c728f2020b55ad10fa9de432ca25a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:28:18 +0000 Subject: [PATCH 7/7] fix: preserve signal exit status --- src/commands/signal-handler.test.ts | 15 +++++++++++++++ src/commands/signal-handler.ts | 12 ++++++++++-- src/services/router-service.ts | 8 +++++++- tests/integration/model-routing.test.ts | 3 ++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/commands/signal-handler.test.ts b/src/commands/signal-handler.test.ts index a21c50d3d..55d0f5c74 100644 --- a/src/commands/signal-handler.test.ts +++ b/src/commands/signal-handler.test.ts @@ -84,6 +84,21 @@ describe('registerSignalHandlers', () => { }, ); + it('exits with the signal status when routing cleanup fails', async () => { + registerSignalHandlers({ + getContainersStarted: () => false, + keepContainers: false, + fastKillAgentContainer: jest.fn().mockResolvedValue(undefined), + performCleanup: jest.fn().mockResolvedValue(undefined), + cleanupRouting: jest.fn(() => { throw new Error('cleanup failed'); }), + }); + + harness.handlers.SIGTERM(); + await flushPromises(); + + expect(harness.processExitSpy).toHaveBeenCalledWith(143); + }); + it('skips fast-kill on SIGINT when containers are not started', async () => { const { fastKill, performCleanup } = await runSignalScenario({ signal: 'SIGINT', diff --git a/src/commands/signal-handler.ts b/src/commands/signal-handler.ts index de898b4e2..800f7b927 100644 --- a/src/commands/signal-handler.ts +++ b/src/commands/signal-handler.ts @@ -37,7 +37,11 @@ export function registerSignalHandlers({ } await performCleanup('SIGINT'); } finally { - cleanupRouting(); + try { + cleanupRouting(); + } catch { + // Cleanup failure must not change the signal exit status. + } console.error(`Process exiting with code: 130`); process.exit(130); // Standard exit code for SIGINT } @@ -52,7 +56,11 @@ export function registerSignalHandlers({ } await performCleanup('SIGTERM'); } finally { - cleanupRouting(); + try { + cleanupRouting(); + } catch { + // Cleanup failure must not change the signal exit status. + } console.error(`Process exiting with code: 143`); process.exit(143); // Standard exit code for SIGTERM } diff --git a/src/services/router-service.ts b/src/services/router-service.ts index 78113641f..ce6dffc82 100644 --- a/src/services/router-service.ts +++ b/src/services/router-service.ts @@ -7,6 +7,12 @@ export const ROUTING_NETWORK_NAME = 'awf-routing'; export const ROUTER_SERVICE_NAME = 'router'; export const ROUTER_DNS_NAME = 'gh-aw-router'; const ROUTER_PORT = 8737; +export const ROUTER_HEALTHCHECK_TEST = [ + 'CMD', + 'python', + '-c', + `import urllib.request; urllib.request.urlopen('http://localhost:${ROUTER_PORT}/healthz', timeout=1).close()`, +]; interface RouterServiceParams { imageConfig: ImageBuildConfig; @@ -22,7 +28,7 @@ export function buildRouterService({ imageConfig }: RouterServiceParams): any { }, }, healthcheck: { - test: ['CMD', 'python', '-c', `import urllib.request; urllib.request.urlopen('http://localhost:${ROUTER_PORT}/healthz', timeout=1).close()`], + test: ROUTER_HEALTHCHECK_TEST, interval: '2s', timeout: '3s', retries: 15, diff --git a/tests/integration/model-routing.test.ts b/tests/integration/model-routing.test.ts index 7e0f78759..1d2ce4096 100644 --- a/tests/integration/model-routing.test.ts +++ b/tests/integration/model-routing.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import execa = require('execa'); import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import { ROUTER_HEALTHCHECK_TEST } from '../../src/services/router-service'; import { cleanup } from '../fixtures/cleanup'; const ROUTER_IMAGE = 'ghcr.io/githubnext/gh-aw-router:latest@sha256:d1612d0eaec3fa8f14c38bbd0a6a0682732fc9f83b7fec94219d3e757a048270'; @@ -24,7 +25,7 @@ describe('Model routing', () => { ' routing:', ' aliases: [gh-aw-router]', ' healthcheck:', - ` test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8737/healthz', timeout=1).close()"]`, + ` test: ${JSON.stringify(ROUTER_HEALTHCHECK_TEST)}`, ' interval: 2s', ' timeout: 3s', ' retries: 15',