diff --git a/CLAUDE.md b/CLAUDE.md index c2d2f2a9e..4f40180e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - **[docs/enclaves-architecture.md](docs/enclaves-architecture.md)** - Unified enclave architecture, MCP gateway handoff, and coverage notes - **[docs/cloud-hypervisor-foundation.md](docs/cloud-hypervisor-foundation.md)** - Cloud Hypervisor v53.0 microVM backend (preview): REST API client, secure launcher (network-namespace join + privilege drop + Landlock/seccomp in place of a jailer), manager/backend, GitHub-hosted Ubuntu x86_64 KVM runners only - **[docs/apple-container-runtime.md](docs/apple-container-runtime.md)** - Apple Container microVM backend (preview): `--container-runtime apple-container`, self-hosted bare-metal Apple Silicon macOS 26+ only, `--network none` guest reached solely through the published-socket capability transport, infrastructure-only Docker Compose with loopback-scoped port publication -- **[docs/apple-container-transport.md](docs/apple-container-transport.md)** - Host/guest capability transport design and threat model for the Apple Container backend +- **[docs/apple-container-transport.md](docs/apple-container-transport.md)** - Host/guest capability transport design and threat model for the Apple Container backend, including the externally owned ordinary MCP gateway upstream (`appleContainer.mcpGatewayUpstreamPort`) ## Development Workflow diff --git a/docs/apple-container-runtime.md b/docs/apple-container-runtime.md index 5ee49ab6c..ca4402b6a 100644 --- a/docs/apple-container-runtime.md +++ b/docs/apple-container-runtime.md @@ -122,6 +122,41 @@ appleContainer: | `--apple-container-memory` | `appleContainer.memory` | `8G` | Integer with an optional `K`/`M`/`G`/`T`/`P` suffix. | | `--apple-container-init-image` | `appleContainer.initImage` | derived from registry/tag | Must be digest-pinned. | | `--apple-container-cli` | `appleContainer.cliPath` | `container` on `PATH` | Absolute path when the CLI is not on `PATH`. | +| `--apple-container-mcp-gateway-upstream-port` | `appleContainer.mcpGatewayUpstreamPort` | unset | Host loopback port of an externally started ordinary MCP gateway. See [MCP gateway](#mcp-gateway). | + +### MCP gateway + +gh-aw runs its MCP gateway (`awmg-mcpg`) itself, with a plain `docker run` +outside AWF's Compose project, and publishes it on host loopback with its own +authentication. AWF cannot rewrite a publication it does not own, so gh-aw +passes only the port: + +```yaml +appleContainer: + previewEnabled: true + mcpGatewayUpstreamPort: 9100 +``` + +AWF then relays `127.0.0.1:` through an AWF-owned Unix socket into the +guest, where the workload reaches it at `http://127.0.0.1:8080` +(`AWF_APPLE_TRANSPORT_MCP_GATEWAY_URL`). The guest still has no NIC. + +- **Only a port is accepted**, an integer in `1..65535`. The upstream host is + fixed to `127.0.0.1` and is not configurable, so this cannot point a guest + capability at another machine. A port reserved for AWF infrastructure (Squid, + any API proxy provider port, the CLI proxy) is rejected regardless of which + of those sidecars this run enables. +- **Valid only on this runtime.** Setting it with any other + `--container-runtime` is an error, not a silently ignored field; every other + runtime reaches the gateway over its own Docker network. +- **AWF publishes nothing for it** and requires no Compose service, so the + gateway's port already being in use is the normal case rather than a + startup conflict. +- **Configuring it is not readiness.** AWF health-probes the upstream before + any relay binds; if the gateway is not listening, the transport rolls back + and the agent never starts. +- **This is ordinary MCP infrastructure, not enclave support.** Enclaves stay + rejected on this runtime (see below). ## Supported and unsupported @@ -134,7 +169,8 @@ appleContainer: | Agent timeout, signals, exit codes | ✅ Exit codes propagate verbatim; a timeout kills the VM and reports `124`. | | Diagnostics and `--keep-containers` | ✅ See [Diagnostics](#diagnostics-and-preservation). | | **Google Vertex AI** | ❌ The Vertex provider port is not in the transport allowlist. Rejected at validation rather than silently losing its endpoint. | -| **Enclaves** | ❌ The enclave MCP gateway is a Docker-network peer that has not been proven reachable from a NIC-less guest. | +| Ordinary MCP gateway (gh-aw `awmg-mcpg`) | ✅ Bridged as a capability when `appleContainer.mcpGatewayUpstreamPort` is set. See [MCP gateway](#mcp-gateway). | +| **Enclaves** | ❌ The enclave subsystem is a set of Docker-network peers that have not been proven reachable from a NIC-less guest. Rejected regardless of `mcpGatewayUpstreamPort`. | | **`--topology-attach`** | ❌ Externally owned peers are not published to macOS loopback, so they cannot be bridged. | | **Docker-in-Docker / ARC split filesystems** | ❌ The guest never receives a Docker socket. | | **`--enable-host-access`, `--allow-host-ports`** | ❌ Only allowlisted capability sockets cross the boundary. | diff --git a/docs/apple-container-transport.md b/docs/apple-container-transport.md index cbc2c527d..1ff5b8204 100644 --- a/docs/apple-container-transport.md +++ b/docs/apple-container-transport.md @@ -17,7 +17,9 @@ DoH, IPv6, raw sockets, the `169.254.169.254` metadata address, and every host network path simply do not exist inside the VM. A NIC-less guest still has to reach a handful of AWF services that keep running -under Docker Compose. The only transport Apple Container offers such a guest is +under Docker Compose, plus — when configured — one ordinary MCP gateway that a +caller such as gh-aw starts on host loopback outside the AWF Compose project. +The only transport Apple Container offers such a guest is `--publish-socket host_path:container_path`, which exposes one host Unix socket at one guest path and works with no NIC attached. Most tooling — curl, npm, pip, the agent CLIs — speaks TCP to a proxy endpoint and cannot be pointed at a Unix @@ -69,6 +71,42 @@ requires. A capability whose socket was never published fails to dial, and the connection is closed with no data — the same fail-closed outcome as a disabled capability. +## Externally owned upstreams + +Most capabilities front an AWF Compose sidecar, so AWF both publishes the host +port (loopback-scoped) and relays it. One capability does not: the ordinary MCP +gateway. gh-aw starts `awmg-mcpg` itself with a plain `docker run`, outside the +AWF Compose project, and binds it to `127.0.0.1`. AWF has no Compose service to +rewrite and no port to publish, so it is told only the port number, through +`appleContainer.mcpGatewayUpstreamPort` (`--apple-container-mcp-gateway-upstream-port`). + +Consequences of that split, all of them deliberate: + +- **Capabilities are a superset of publications.** `planAppleContainerInfrastructure` + emits the external capability with no publication and no entry in + `plan.services`, so `applyAppleContainerLoopbackPublishing` never searches the + Compose output for a service AWF does not generate, and the preflight port + conflict probe never reports the gateway's own listener as a collision. +- **Only a port is configurable.** The upstream host is fixed to `127.0.0.1` in + code, so this setting cannot widen the set of addresses a relay will dial. It + is re-validated through the same loopback/private-address predicate every + other upstream passes, and any port reserved for AWF infrastructure is + refused — otherwise the guest's MCP gateway endpoint could silently front + Squid or a credential-injecting API proxy port. The reserved set is fixed, so + the guard does not depend on which sidecars a given run enables. +- **The guest shape is unchanged.** The guest still reaches the gateway at the + compiled-in `http://127.0.0.1:8080` (`AWF_APPLE_TRANSPORT_MCP_GATEWAY_URL`), + whatever the host port is. No contract version bump: the host port is a host + dial target, not part of the host/guest agreement. +- **Configuration is not readiness.** Setting the port proves nothing. The + transport's upstream health probe must connect to the gateway before any relay + binds, and a gateway that is not up rolls the whole transport back and + prevents agent execution. + +This is ordinary MCP infrastructure, not enclave support. Enclaves remain +rejected outright by `assertAppleContainerPreSecurityCompatibility`, as does +`--topology-attach`. + ## Contract versioning The host half (`transport-capabilities.ts`) and the guest half (`contract.go`) diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index b288ad437..12ac52529 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -186,6 +186,18 @@ supported/unsupported matrix, and [docs/apple-container-transport.md](./apple-container-transport.md) for the transport's own threat model. +`appleContainer.mcpGatewayUpstreamPort` bridges one *ordinary* MCP gateway that +a caller starts on host loopback outside AWF's Compose project (gh-aw's +`awmg-mcpg`). AWF MUST accept only a TCP port in `1..65535`; the upstream host +is fixed to `127.0.0.1` and MUST NOT be configurable. AWF MUST NOT publish a +Compose port or require a Compose service for it, MUST reject any port reserved +for AWF infrastructure services, MUST reject the setting on any other +`containerRuntime`, and MUST +health-probe the upstream before the agent starts. The guest reaches the gateway +at the compiled-in `http://127.0.0.1:8080` through +`AWF_APPLE_TRANSPORT_MCP_GATEWAY_URL`. This is ordinary MCP infrastructure and +does not enable enclaves, which remain rejected on this runtime. + Live end-to-end validation runs only on a self-hosted bare-metal Apple Silicon runner via [`smoke-apple-container.yml`](../.github/workflows/smoke-apple-container.yml), @@ -319,6 +331,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `appleContainer.memory` → `--apple-container-memory` - `appleContainer.initImage` → `--apple-container-init-image` - `appleContainer.cliPath` → `--apple-container-cli` +- `appleContainer.mcpGatewayUpstreamPort` → `--apple-container-mcp-gateway-upstream-port` *(host loopback port of an externally started ordinary MCP gateway; host fixed to `127.0.0.1`, valid only with `container.containerRuntime: "apple-container"`)* - `chroot.binariesSourcePath` → *(config-only; mounts a runner-side binaries directory at `/tmp/awf-runner-bin` inside chroot mode and prepends it to `PATH`)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 5bca89f51..45e3b7c82 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -844,6 +844,12 @@ "cliPath": { "type": "string", "description": "Absolute path to the Apple \"container\" CLI when it is not on PATH." + }, + "mcpGatewayUpstreamPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Host loopback TCP port of an ordinary MCP gateway started outside AWF (for example gh-aw's awmg-mcpg). AWF relays 127.0.0.1: into the NIC-less guest through the existing mcp-gateway capability, reachable at http://127.0.0.1:8080 inside the guest. The upstream host is fixed to 127.0.0.1 and is not configurable. AWF publishes no port and requires no Compose service for this capability, rejects any port reserved for AWF infrastructure services, and health-probes the upstream before the agent starts. This is ordinary MCP infrastructure, not enclave support; enclaves remain rejected by this runtime. Valid only with container.containerRuntime: \"apple-container\"." } } }, diff --git a/src/apple-container-mcp-gateway-upstream.test.ts b/src/apple-container-mcp-gateway-upstream.test.ts new file mode 100644 index 000000000..42a86e05f --- /dev/null +++ b/src/apple-container-mcp-gateway-upstream.test.ts @@ -0,0 +1,258 @@ +/** + * End-to-end coverage for `appleContainer.mcpGatewayUpstreamPort`. + * + * The option exists so a caller such as gh-aw, which starts an ordinary MCP + * gateway outside the AWF Compose project and binds it to macOS loopback, can + * hand AWF just a port number. Everything else about the path is fixed by AWF: + * the upstream host, the guest port, the socket name, and the requirement that + * the upstream is actually listening before the agent starts. + * + * These tests cover the configuration surface (CLI parsing, config-file + * mapping, JSON Schema) and the misuse cases. Planning, Compose behaviour, and + * transport readiness are covered in + * `src/apple-container/infrastructure-endpoints.test.ts` and + * `src/apple-container/transport-manager.test.ts`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import Ajv2020 from 'ajv/dist/2020'; + +import { buildConfig } from './commands/build-config'; +import { mapAwfFileConfigToCliOptions } from './config-mapper'; +import { + assertAppleContainerRuntimeCompatibility, + assertAppleContainerSelection, +} from './apple-container/runtime-validation'; +import type { AwfFileConfig } from './config-file'; +import type { WrapperConfig } from './types'; + +function inputs(options: Record = {}): Parameters[0] { + return { + options: { + keepContainers: false, + tty: false, + workDir: '/tmp/awf-test', + buildLocal: false, + skipPull: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + envAll: false, + enableHostAccess: false, + sslBump: false, + enableDind: false, + enableDlp: false, + enableApiProxy: false, + anthropicAutoCache: false, + diagnosticLogs: false, + ...options, + }, + agentCommand: 'echo hello', + logLevel: 'info', + allowedDomains: ['github.com'], + blockedDomains: [], + localhostDetected: false, + additionalEnv: {}, + volumeMounts: undefined, + upstreamProxy: undefined, + dnsServers: ['8.8.8.8'], + dnsOverHttps: undefined, + allowedUrls: undefined, + memoryLimit: undefined, + pidsLimit: undefined, + agentImage: undefined, + modelAliases: undefined, + allowedModels: undefined, + disallowedModels: undefined, + maxEffectiveTokens: undefined, + maxAiCredits: undefined, + effectiveTokenModelMultipliers: undefined, + effectiveTokenDefaultModelMultiplier: undefined, + maxRuns: undefined, + maxPermissionDenied: undefined, + maxCacheMisses: undefined, + resolvedCopilotApiTarget: undefined, + resolvedCopilotApiBasePath: undefined, + dockerHostPathPrefix: undefined, + } as unknown as Parameters[0]; +} + +describe('--apple-container-mcp-gateway-upstream-port parsing', () => { + it('parses a valid port onto the Apple Container config', () => { + const config = buildConfig(inputs({ + containerRuntime: 'apple-container', + appleContainerPreview: true, + appleContainerMcpGatewayUpstreamPort: '9100', + })); + expect(config.appleContainer?.mcpGatewayUpstreamPort).toBe(9100); + }); + + it('leaves the field absent when the flag is not passed', () => { + const config = buildConfig(inputs({ + containerRuntime: 'apple-container', + appleContainerPreview: true, + })); + expect(config.appleContainer).toBeDefined(); + expect(config.appleContainer).not.toHaveProperty('mcpGatewayUpstreamPort'); + }); + + it('materialises Apple Container config when only this flag is passed', () => { + // Otherwise the option would be silently dropped and the misuse guard in + // runtime-validation would never see it. + const config = buildConfig(inputs({ appleContainerMcpGatewayUpstreamPort: '9100' })); + expect(config.appleContainer?.mcpGatewayUpstreamPort).toBe(9100); + }); + + it.each(['0', '65536', '-1', '1.5', 'nine', '', '0x10', '9100abc'])( + 'rejects the invalid port %p', + (value) => { + expect(() => buildConfig(inputs({ + containerRuntime: 'apple-container', + appleContainerPreview: true, + appleContainerMcpGatewayUpstreamPort: value, + }))).toThrow('--apple-container-mcp-gateway-upstream-port must be an integer TCP port'); + }, + ); + + it.each(['1', '65535'])('accepts the boundary port %p', (value) => { + const config = buildConfig(inputs({ + containerRuntime: 'apple-container', + appleContainerPreview: true, + appleContainerMcpGatewayUpstreamPort: value, + })); + expect(config.appleContainer?.mcpGatewayUpstreamPort).toBe(Number(value)); + }); +}); + +describe('config-file mapping', () => { + it('maps appleContainer.mcpGatewayUpstreamPort onto the CLI option', () => { + const options = mapAwfFileConfigToCliOptions({ + container: { containerRuntime: 'apple-container' }, + appleContainer: { previewEnabled: true, mcpGatewayUpstreamPort: 9100 }, + } as AwfFileConfig); + expect(options.appleContainerMcpGatewayUpstreamPort).toBe('9100'); + }); + + it('omits the option when the config file does not set it', () => { + const options = mapAwfFileConfigToCliOptions({ + appleContainer: { previewEnabled: true }, + } as AwfFileConfig); + expect(options.appleContainerMcpGatewayUpstreamPort).toBeUndefined(); + }); + + it('round-trips a config file value into the assembled config', () => { + const mapped = mapAwfFileConfigToCliOptions({ + container: { containerRuntime: 'apple-container' }, + appleContainer: { previewEnabled: true, mcpGatewayUpstreamPort: 9100 }, + } as AwfFileConfig); + const config = buildConfig(inputs({ ...mapped })); + expect(config.appleContainer?.mcpGatewayUpstreamPort).toBe(9100); + }); +}); + +describe('awf-config.schema.json', () => { + const schemaPath = path.join(__dirname, '..', 'docs', 'awf-config.schema.json'); + let validate: ReturnType; + + beforeAll(() => { + const ajv = new Ajv2020({ allErrors: true }); + ajv.addKeyword({ keyword: 'version' }); + validate = ajv.compile(JSON.parse(fs.readFileSync(schemaPath, 'utf8'))); + }); + + it('accepts an in-range port', () => { + expect(validate({ appleContainer: { previewEnabled: true, mcpGatewayUpstreamPort: 9100 } })) + .toBe(true); + }); + + it.each([0, 65_536, 1.5, '9100', null])('rejects %p', (value) => { + expect(validate({ appleContainer: { mcpGatewayUpstreamPort: value } })).toBe(false); + }); + + it('offers no companion host/address setting', () => { + // The upstream host is pinned to 127.0.0.1 in code; a configurable host + // would widen the set of addresses AWF is willing to dial from the relay. + expect(validate({ appleContainer: { mcpGatewayUpstreamHost: '10.0.0.5' } })).toBe(false); + }); + + it('matches the bundled runtime copy of the schema', () => { + const bundled = fs.readFileSync(path.join(__dirname, 'awf-config-schema.json'), 'utf8'); + const docs = JSON.parse(fs.readFileSync(schemaPath, 'utf8')) as Record; + const runtime = JSON.parse(bundled) as Record; + expect((runtime.properties as Record).appleContainer) + .toEqual((docs.properties as Record).appleContainer); + }); +}); + +describe('runtime compatibility', () => { + function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'true', + logLevel: 'info', + workDir: '/tmp/awf-test', + containerRuntime: 'apple-container', + networkIsolation: true, + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 9100, + }, + ...overrides, + } as unknown as WrapperConfig; + } + + it('accepts the option on the Apple Container runtime with preview enabled', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config())).not.toThrow(); + }); + + it('rejects the option on another runtime with an actionable message', () => { + expect(() => assertAppleContainerSelection(config({ containerRuntime: 'gvisor' }))) + .toThrow(/mcpGatewayUpstreamPort .*requires --container-runtime apple-container/s); + }); + + it('rejects the option on the default Docker runtime', () => { + expect(() => assertAppleContainerSelection(config({ containerRuntime: undefined }))) + .toThrow('mcpGatewayUpstreamPort'); + }); + + it('still requires the preview opt-in', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ + appleContainer: { + previewEnabled: false, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 9100, + }, + }))).toThrow('requires explicit --apple-container-preview opt-in'); + }); + + it('re-validates a port injected past the CLI parser', () => { + // A config file or programmatic caller can populate appleContainer directly. + expect(() => assertAppleContainerRuntimeCompatibility(config({ + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 70_000, + }, + }))).toThrow('mcpGatewayUpstreamPort must be an integer TCP port in 1..65535'); + }); + + it('keeps rejecting enclaves even with the gateway port configured', () => { + // The ordinary MCP gateway is not enclave support; enabling enclaves must + // still fail closed. + expect(() => assertAppleContainerRuntimeCompatibility(config({ + enclaves: { enabled: true }, + } as unknown as Partial))) + .toThrow('does not yet support enclaves'); + }); + + it('keeps rejecting topology attach even with the gateway port configured', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ + topologyAttach: ['awmg-mcpg'], + }))).toThrow('does not support --topology-attach'); + }); +}); diff --git a/src/apple-container-runtime-backend.test.ts b/src/apple-container-runtime-backend.test.ts index fba54f5f5..da44c0990 100644 --- a/src/apple-container-runtime-backend.test.ts +++ b/src/apple-container-runtime-backend.test.ts @@ -234,6 +234,29 @@ describe('start', () => { expect(options.initImage).toBe(INIT_IMAGE); }); + it('requests the externally started MCP gateway without probing its port', async () => { + const h = harness(config({ + enableApiProxy: false, + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 9_100, + }, + })); + await start(h); + + const options = (h.dependencies.startTransport as jest.Mock).mock.calls[0][0]; + expect(options.capabilities).toEqual([ + { id: 'squid', upstream: { host: '127.0.0.1', port: 3128 } }, + { id: 'mcp-gateway', upstream: { host: '127.0.0.1', port: 9_100 } }, + ]); + // gh-aw owns the gateway's publication, so AWF must not treat its live + // listener as a startup conflict. + const plan = (h.dependencies.findPortConflicts as jest.Mock).mock.calls[0][0]; + expect(plan.publications.map((entry: { hostPort: number }) => entry.hostPort)).toEqual([3128]); + }); + it('merges the transport plan into the run spec before creating the container', async () => { const h = harness(); await start(h); diff --git a/src/apple-container-runtime-backend.ts b/src/apple-container-runtime-backend.ts index 112deb2ab..465241765 100644 --- a/src/apple-container-runtime-backend.ts +++ b/src/apple-container-runtime-backend.ts @@ -343,6 +343,15 @@ export class AppleContainerRuntimeBackend implements ExternalAgentRuntimeBackend plan.capabilities.map((capability) => capability.id).join(', ') }`, ); + for (const upstream of plan.externalUpstreams) { + // Externally owned upstreams are the one part of the capability set AWF + // does not start or publish, so name them explicitly: a gateway that is + // up but wrong is otherwise indistinguishable from an AWF sidecar. + this.dependencies.logger.info( + `[apple-container] capability ${upstream.capability} relays to externally started ` + + `127.0.0.1:${upstream.hostPort}`, + ); + } stage = 'container-create'; // Resolved once: the default implementation stats the filesystem, and a diff --git a/src/apple-container/infrastructure-endpoints.test.ts b/src/apple-container/infrastructure-endpoints.test.ts index 6bb1fbed3..7a21ee68b 100644 --- a/src/apple-container/infrastructure-endpoints.test.ts +++ b/src/apple-container/infrastructure-endpoints.test.ts @@ -87,6 +87,102 @@ describe('planAppleContainerInfrastructure', () => { }); }); +describe('planAppleContainerInfrastructure external MCP gateway upstream', () => { + function withGateway(port: number, overrides: Partial = {}): WrapperConfig { + return config({ + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: port, + }, + ...overrides, + }); + } + + it('adds the capability without adding a publication or a Compose service', () => { + const plan = planAppleContainerInfrastructure(withGateway(9_100)); + expect(plan.capabilities.map((entry) => entry.id)).toEqual(['squid', 'mcp-gateway']); + // The gateway is started by gh-aw outside the AWF Compose project, so AWF + // must neither publish a port for it nor demand a service that publishes one. + expect(plan.publications.map((entry) => entry.capability)).toEqual(['squid']); + expect(plan.services).toEqual(['squid-proxy']); + expect(plan.externalUpstreams).toEqual([{ capability: 'mcp-gateway', hostPort: 9_100 }]); + }); + + it('relays to loopback and never to a caller-supplied host', () => { + const plan = planAppleContainerInfrastructure(withGateway(9_100)); + const gateway = plan.capabilities.find((entry) => entry.id === 'mcp-gateway'); + expect(gateway?.upstream).toEqual({ host: APPLE_CONTAINER_LOOPBACK_HOST, port: 9_100 }); + }); + + it('appends after every AWF-owned capability so Squid stays present', () => { + const plan = planAppleContainerInfrastructure( + withGateway(9_100, { enableApiProxy: true, difcProxyHost: 'https://difc:18443' }), + ); + expect(plan.capabilities.map((entry) => entry.id)).toEqual([ + 'squid', + 'api-proxy-openai', + 'api-proxy-anthropic', + 'api-proxy-copilot', + 'api-proxy-gemini', + 'cli-proxy', + 'mcp-gateway', + ]); + expect(plan.services).toEqual(['squid-proxy', 'api-proxy', 'cli-proxy']); + }); + + it('names only capabilities that exist in the layer-2 allowlist', () => { + const allowed = new Set(APPLE_CONTAINER_TRANSPORT_CAPABILITIES.map((entry) => entry.id)); + for (const capability of planAppleContainerInfrastructure(withGateway(9_100)).capabilities) { + expect(allowed.has(capability.id as never)).toBe(true); + } + }); + + it('is absent, and the plan unchanged, when the option is not set', () => { + const withoutOption = planAppleContainerInfrastructure(config({ enableApiProxy: true })); + expect(withoutOption.externalUpstreams).toEqual([]); + expect(withoutOption.capabilities.map((entry) => entry.id)).not.toContain('mcp-gateway'); + }); + + it.each([0, -1, 65_536, 1.5, Number.NaN])('rejects the out-of-range port %p', (port) => { + expect(() => planAppleContainerInfrastructure(withGateway(port))) + .toThrow('port must be in 1..65535'); + }); + + it.each([1, 65_535])('accepts the boundary port %p', (port) => { + const plan = planAppleContainerInfrastructure(withGateway(port)); + expect(plan.externalUpstreams).toEqual([{ capability: 'mcp-gateway', hostPort: port }]); + }); + + it('refuses a port AWF itself publishes so the guest cannot front Squid', () => { + expect(() => planAppleContainerInfrastructure(withGateway(SQUID_PORT))) + .toThrow(`mcpGatewayUpstreamPort ${SQUID_PORT} is reserved for AWF infrastructure`); + }); + + it('refuses a credential-injecting API proxy port', () => { + const anthropic = apiProxyPorts().anthropic; + expect(() => planAppleContainerInfrastructure( + withGateway(anthropic, { enableApiProxy: true }), + )).toThrow(`mcpGatewayUpstreamPort ${anthropic} is reserved for AWF infrastructure`); + }); + + it('refuses a reserved port even when this run does not publish it', () => { + // The guard must not depend on which sidecars this configuration enables: + // an API proxy port is reserved whether or not --enable-api-proxy is set, + // and so is the Vertex port that no capability can carry. + for (const port of [apiProxyPorts().anthropic, apiProxyPorts().vertex, CLI_PROXY_PORT]) { + expect(() => planAppleContainerInfrastructure(withGateway(port))) + .toThrow('is reserved for AWF infrastructure'); + } + }); + + it('accepts a port outside the reserved set', () => { + const plan = planAppleContainerInfrastructure(withGateway(9_100)); + expect(plan.externalUpstreams).toEqual([{ capability: 'mcp-gateway', hostPort: 9_100 }]); + }); +}); + describe('appleContainerPortMapping', () => { it('binds the publication to loopback only', () => { const [publication] = planAppleContainerInfrastructure(config()).publications; @@ -128,6 +224,24 @@ describe('applyAppleContainerLoopbackPublishing', () => { planAppleContainerInfrastructure(config()), )).toThrow('requires the "squid-proxy" Compose service'); }); + + it('never looks for a Compose service backing an external MCP gateway', () => { + const services: Record = { 'squid-proxy': {} }; + const plan = planAppleContainerInfrastructure(config({ + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 9_100, + }, + })); + // `awmg-mcpg` is gh-aw's own container; demanding it here would fail every + // Apple Container run that configures the gateway. + expect(() => applyAppleContainerLoopbackPublishing(services, plan)).not.toThrow(); + expect(Object.keys(services)).toEqual(['squid-proxy']); + expect((services['squid-proxy'] as { ports: string[] }).ports) + .toEqual([`127.0.0.1:${SQUID_PORT}:${SQUID_PORT}`]); + }); }); describe('appleContainerLoopbackPortConflicts', () => { @@ -158,4 +272,26 @@ describe('appleContainerLoopbackPortConflicts', () => { ); expect(conflicts).toEqual([]); }); + + it('never treats the externally owned MCP gateway port as a conflict', async () => { + const plan = planAppleContainerInfrastructure(config({ + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + mcpGatewayUpstreamPort: 9_100, + }, + })); + const probed: number[] = []; + const conflicts = await appleContainerLoopbackPortConflicts(plan, { + isPortInUse: async (port) => { + probed.push(port); + // gh-aw's gateway is already listening; that is the expected state, not + // a collision AWF should refuse to start over. + return port === 9_100; + }, + }); + expect(conflicts).toEqual([]); + expect(probed).toEqual([SQUID_PORT]); + }); }); diff --git a/src/apple-container/infrastructure-endpoints.ts b/src/apple-container/infrastructure-endpoints.ts index b27b8380d..82336d5bf 100644 --- a/src/apple-container/infrastructure-endpoints.ts +++ b/src/apple-container/infrastructure-endpoints.ts @@ -20,8 +20,12 @@ * not merely add to them. * 2. **Exactly the required ports.** The publication set is derived from the * same configuration that derives the capability set, so a port is published - * if and only if a capability relays to it. In particular the Vertex - * provider port is never published, because no capability can carry it. + * if and only if an AWF-owned capability relays to it. In particular the + * Vertex provider port is never published, because no capability can carry + * it. Capabilities are a superset of publications rather than a mirror of + * them: a capability may instead front an *externally* started host loopback + * listener (the ordinary gh-aw MCP gateway), which AWF relays to but never + * publishes, because it is not an AWF Compose service. * 3. **Validated before use.** {@link appleContainerLoopbackPortConflicts} * proves the fixed ports are free before Compose is started, so a port that * is already in use fails with an actionable message instead of producing a @@ -33,6 +37,7 @@ import * as net from 'net'; import { apiProxyPorts, CLI_PROXY_PORT, SQUID_PORT } from '../config/network-policy'; import type { WrapperConfig } from '../types'; import type { AppleContainerCapabilityId } from './transport-capabilities'; +import { assertAppleContainerUpstreamEndpoint } from './transport-capabilities'; import type { AppleContainerTransportCapabilityRequest } from './transport-plan'; /** @@ -53,11 +58,32 @@ export interface AppleContainerPortPublication { readonly capability: AppleContainerCapabilityId; } +/** + * A capability backed by a host listener AWF neither starts nor publishes. + * + * gh-aw launches its ordinary MCP gateway (`awmg-mcpg`) with a plain + * `docker run` outside the AWF Compose project and binds it to macOS loopback + * itself. AWF therefore has no Compose service to rewrite and no port to + * publish — it only relays. Such a capability appears in + * {@link AppleContainerInfrastructurePlan.capabilities} but deliberately never + * in `publications` or `services`, so + * {@link applyAppleContainerLoopbackPublishing} never searches for a Compose + * service that does not exist and {@link appleContainerLoopbackPortConflicts} + * never reports the externally owned listener as a conflict — its port being + * occupied is the normal case, not an error. + */ +export interface AppleContainerExternalUpstream { + readonly capability: AppleContainerCapabilityId; + readonly hostPort: number; +} + export interface AppleContainerInfrastructurePlan { readonly publications: readonly AppleContainerPortPublication[]; readonly capabilities: readonly AppleContainerTransportCapabilityRequest[]; /** Compose services that must publish at least one loopback port. */ readonly services: readonly AppleContainerInfrastructureService[]; + /** Capabilities relayed to externally owned host loopback listeners. */ + readonly externalUpstreams: readonly AppleContainerExternalUpstream[]; } /** Provider ports the transport allowlist covers, in capability order. */ @@ -80,6 +106,12 @@ function apiProxyPublications(): readonly Omit Object.freeze({ ...entry, hostPort: entry.containerPort })); const services = [...new Set(publications.map((entry) => entry.service))]; + const externalUpstreams = planExternalUpstreams(config); + + const capabilities = [ + ...publications.map((entry) => ({ id: entry.capability, hostPort: entry.hostPort })), + ...externalUpstreams.map((entry) => ({ id: entry.capability, hostPort: entry.hostPort })), + ].map((entry) => Object.freeze({ + id: entry.id, + upstream: Object.freeze({ host: APPLE_CONTAINER_LOOPBACK_HOST, port: entry.hostPort }), + })); return Object.freeze({ publications: Object.freeze(publications), - capabilities: Object.freeze(publications.map((entry) => Object.freeze({ - id: entry.capability, - upstream: Object.freeze({ host: APPLE_CONTAINER_LOOPBACK_HOST, port: entry.hostPort }), - }))), + capabilities: Object.freeze(capabilities), services: Object.freeze(services), + externalUpstreams: Object.freeze(externalUpstreams), }); } +/** + * Resolves capabilities that relay to host listeners AWF does not own. + * + * Today the only entry is the ordinary MCP gateway gh-aw starts outside the AWF + * Compose project. Two guards apply beyond the parse-time port check: + * + * 1. The port is re-validated through the same allowlist predicate every other + * upstream passes, so a value that reached here from a non-CLI path (config + * file, programmatic caller) cannot skip validation. + * 2. Any port AWF reserves for its own infrastructure is refused. Accepting one + * could front an AWF sidecar — Squid or a credential-injecting API proxy + * port — on the guest's MCP gateway endpoint instead of the gateway the + * operator meant. The comparison is against the fixed reserved set rather + * than this run's publications, so the guard does not depend on which + * sidecars this particular configuration happens to enable. + */ +function planExternalUpstreams(config: WrapperConfig): AppleContainerExternalUpstream[] { + const port = config.appleContainer?.mcpGatewayUpstreamPort; + if (port === undefined) return []; + + assertAppleContainerUpstreamEndpoint( + { host: APPLE_CONTAINER_LOOPBACK_HOST, port }, + 'capability mcp-gateway', + ); + + if (reservedAwfLoopbackPorts().has(port)) { + throw new Error( + `Apple Container mcpGatewayUpstreamPort ${port} is reserved for AWF infrastructure; the ` + + 'external MCP gateway must listen on its own host loopback port', + ); + } + + return [Object.freeze({ capability: 'mcp-gateway' as const, hostPort: port })]; +} + +/** + * Every loopback port AWF may publish for its own sidecars, regardless of the + * current configuration. + * + * Includes the Vertex provider port even though no capability can carry it: it + * is still a port an AWF sidecar can bind, so it must not be relayed to either. + */ +function reservedAwfLoopbackPorts(): ReadonlySet { + return new Set([SQUID_PORT, CLI_PROXY_PORT, ...Object.values(apiProxyPorts())]); +} + /** Compose `ports:` entry that binds the publication to loopback only. */ export function appleContainerPortMapping(publication: AppleContainerPortPublication): string { return `${APPLE_CONTAINER_LOOPBACK_HOST}:${publication.hostPort}:${publication.containerPort}`; @@ -131,6 +216,11 @@ interface ComposeServiceLike { * in place would keep an open forward proxy listening on every host interface * while the loopback entry sat harmlessly beside it. * + * Only AWF-owned Compose services are touched. `plan.services` is derived from + * `plan.publications` alone, so an externally owned upstream (gh-aw's + * `awmg-mcpg`) is never looked up here and this function can never demand a + * Compose service AWF does not generate. + * * @throws when a service the plan needs is missing from the Compose output, * which would otherwise surface as an unreachable capability inside the VM. */ @@ -185,6 +275,12 @@ export interface AppleContainerPortProbeDependencies { * Called before Compose starts so a collision (a second concurrent AWF run, a * stray local Squid) is reported as a named conflict rather than as a Docker * bind error buried in Compose output. + * + * Only `plan.publications` is probed. An externally owned upstream is expected + * to already have a listener — that is precisely why AWF relays to it instead + * of publishing it — so probing it would turn the normal case into an error. + * Its actual reachability is proven later, by the transport manager's upstream + * health probe, which refuses to start the agent if the gateway is not up. */ export async function appleContainerLoopbackPortConflicts( plan: AppleContainerInfrastructurePlan, diff --git a/src/apple-container/runtime-validation.ts b/src/apple-container/runtime-validation.ts index 713805511..0a78f54df 100644 --- a/src/apple-container/runtime-validation.ts +++ b/src/apple-container/runtime-validation.ts @@ -48,12 +48,39 @@ export const APPLE_CONTAINER_MAX_TIMEOUT_MS = 86_400_000; */ export function assertAppleContainerSelection(config: WrapperConfig): void { if (config.appleContainer && config.containerRuntime !== APPLE_CONTAINER_RUNTIME) { + const mcpGatewayPort = config.appleContainer.mcpGatewayUpstreamPort; + if (mcpGatewayPort !== undefined) { + throw new Error( + 'appleContainer.mcpGatewayUpstreamPort bridges an external MCP gateway into a NIC-less ' + + `Apple Container guest and requires --container-runtime ${APPLE_CONTAINER_RUNTIME}; ` + + `the ${config.containerRuntime ?? 'docker'} runtime reaches the gateway over its own ` + + 'network and must not set it', + ); + } throw new Error( `Apple Container options require --container-runtime ${APPLE_CONTAINER_RUNTIME}`, ); } } +/** + * Re-validates the MCP gateway upstream port on a fully assembled config. + * + * The CLI parser already enforces this range, but a config file or a + * programmatic caller can populate `appleContainer` directly. A configured + * relay target is a network path into an otherwise NIC-less guest, so it is + * checked again here rather than trusted because one entry point validated it. + */ +function assertAppleContainerMcpGatewayUpstream(appleContainer: AppleContainerOptions): void { + const port = appleContainer.mcpGatewayUpstreamPort; + if (port === undefined) return; + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new Error( + `appleContainer.mcpGatewayUpstreamPort must be an integer TCP port in 1..65535; got ${port}`, + ); + } +} + /** * Guards that must hold *before* `applySecurityMode` mutates the config. * @@ -128,6 +155,7 @@ export function assertAppleContainerRuntimeCompatibility( if (!config.networkIsolation) { throw new Error('Apple Container preview requires strict --network-isolation security'); } + assertAppleContainerMcpGatewayUpstream(appleContainer); assertAppleContainerPreSecurityCompatibility(config); if (config.agentImage && config.agentImage !== 'default') { diff --git a/src/apple-container/transport-capabilities.ts b/src/apple-container/transport-capabilities.ts index de39de4d0..119ca96b4 100644 --- a/src/apple-container/transport-capabilities.ts +++ b/src/apple-container/transport-capabilities.ts @@ -9,10 +9,12 @@ * confine anything; the confinement here comes from the missing NIC. * * The guest still needs to reach a small, fixed set of AWF services that keep - * running under Docker Compose (Squid, the API proxy, the CLI proxy, the - * enclave MCP gateway). The only capability transport Apple Container offers a - * NIC-less VM is `--publish-socket host_path:container_path`, which exposes one - * host Unix socket at one guest path. This module is the single source of truth + * running under Docker Compose (Squid, the API proxy, the CLI proxy) plus, when + * configured, an ordinary MCP gateway that a caller such as gh-aw starts on + * host loopback outside the AWF Compose project. The only capability transport + * Apple Container offers a NIC-less VM is + * `--publish-socket host_path:container_path`, which exposes one host Unix + * socket at one guest path. This module is the single source of truth * for *which* sockets may be published and what they map to: * * ``` @@ -178,7 +180,13 @@ export const APPLE_CONTAINER_TRANSPORT_CAPABILITIES: readonly AppleContainerCapa define('api-proxy-copilot', apiProxyPorts().copilot, 'API proxy: Copilot provider'), define('api-proxy-gemini', apiProxyPorts().gemini, 'API proxy: Gemini provider'), define('cli-proxy', CLI_PROXY_PORT, 'CLI proxy (DIFC-mediated safe outputs)'), - define('mcp-gateway', ENCLAVE_MCP_CONTROL_PORT, 'Enclave MCP gateway streamable HTTP endpoint'), + // Ordinary MCP infrastructure. gh-aw starts its MCP gateway itself, outside + // the AWF Compose project, and AWF is told only the host loopback port to + // relay to (`appleContainer.mcpGatewayUpstreamPort`). The guest port stays + // pinned to the enclave control port value so the host and the compiled + // guest contract agree; it is a shared constant, not enclave support — + // enclaves remain rejected outright by runtime-validation. + define('mcp-gateway', ENCLAVE_MCP_CONTROL_PORT, 'MCP gateway streamable HTTP endpoint'), ].map((capability) => Object.freeze(capability))); const CAPABILITIES_BY_ID = new Map( diff --git a/src/apple-container/transport-manager.test.ts b/src/apple-container/transport-manager.test.ts index c1070914a..c661b4870 100644 --- a/src/apple-container/transport-manager.test.ts +++ b/src/apple-container/transport-manager.test.ts @@ -379,3 +379,77 @@ describe('probeAppleContainerUpstream', () => { .rejects.toThrow(); }); }); + +describe('external MCP gateway capability', () => { + const bases: string[] = []; + const upstreams: Upstream[] = []; + + function newBase(): string { + const base = fs.mkdtempSync('/tmp/awfmg'); + bases.push(base); + return base; + } + + afterEach(async () => { + for (const upstream of upstreams.splice(0)) await upstream.close(); + for (const base of bases.splice(0)) fs.rmSync(base, { recursive: true, force: true }); + }); + + it('bridges an externally started gateway to the fixed guest endpoint', async () => { + const squid = await startUpstream(); + // Stands in for gh-aw's `awmg-mcpg`, which AWF never starts or publishes. + const gateway = await startUpstream(); + upstreams.push(squid, gateway); + const base = newBase(); + + const transport = await startAppleContainerTransport({ + baseDirectory: base, + initImage: INIT_IMAGE, + capabilities: [ + { id: 'squid', upstream: { host: '127.0.0.1', port: squid.port } }, + { id: 'mcp-gateway', upstream: { host: '127.0.0.1', port: gateway.port } }, + ], + }, { logger: silentLogger() }); + + try { + // The guest learns the endpoint only through the compiled contract, which + // pins the mcp-gateway loopback port at 8080 regardless of the host port. + expect(transport.plan.env.AWF_APPLE_TRANSPORT_MCP_GATEWAY_URL) + .toBe('http://127.0.0.1:8080'); + const entry = transport.plan.entries.find((e) => e.capability.id === 'mcp-gateway')!; + expect(entry.guestPort).toBe(8080); + expect(entry.upstream).toEqual({ host: '127.0.0.1', port: gateway.port }); + expect(entry.guestSocketPath).toMatch(/\/mcp-gateway\.sock$/); + // No credential or host port leaks into the guest environment. + expect(JSON.stringify(transport.plan.env)).not.toContain(String(gateway.port)); + expect(transport.stats()['mcp-gateway'].established).toBe(1); + } finally { + await transport.stop(); + } + }); + + it('refuses to start the agent when the gateway is not listening', async () => { + const squid = await startUpstream(); + upstreams.push(squid); + const base = newBase(); + const dead = await startUpstream(); + const deadPort = dead.port; + await dead.close(); + + // Configuring the port does not prove readiness: the upstream health probe + // must fail closed so no agent ever sees a socket that leads nowhere. + await expect(startAppleContainerTransport({ + baseDirectory: base, + initImage: INIT_IMAGE, + capabilities: [ + { id: 'squid', upstream: { host: '127.0.0.1', port: squid.port } }, + { id: 'mcp-gateway', upstream: { host: '127.0.0.1', port: deadPort } }, + ], + health: { attempts: 2, retryDelayMs: 0, timeoutMs: 50 }, + }, { logger: silentLogger(), sleep: jest.fn().mockResolvedValue(undefined) })) + .rejects.toThrow(/mcp-gateway/); + + // Full rollback: the Squid relay that already passed must not survive. + expect(fs.readdirSync(base)).toEqual([]); + }); +}); diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 5bca89f51..45e3b7c82 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -844,6 +844,12 @@ "cliPath": { "type": "string", "description": "Absolute path to the Apple \"container\" CLI when it is not on PATH." + }, + "mcpGatewayUpstreamPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Host loopback TCP port of an ordinary MCP gateway started outside AWF (for example gh-aw's awmg-mcpg). AWF relays 127.0.0.1: into the NIC-less guest through the existing mcp-gateway capability, reachable at http://127.0.0.1:8080 inside the guest. The upstream host is fixed to 127.0.0.1 and is not configurable. AWF publishes no port and requires no Compose service for this capability, rejects any port reserved for AWF infrastructure services, and health-probes the upstream before the agent starts. This is ordinary MCP infrastructure, not enclave support; enclaves remain rejected by this runtime. Valid only with container.containerRuntime: \"apple-container\"." } } }, diff --git a/src/cli-options.ts b/src/cli-options.ts index e5dc45147..17e78e0e1 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -229,6 +229,11 @@ program 'Digest-pinned AWF Apple init image carrying the guest capability relay.' ) .option('--apple-container-cli ', 'Path to the Apple "container" CLI when it is not on PATH.') + .option( + '--apple-container-mcp-gateway-upstream-port ', + 'Host loopback port of an externally started ordinary MCP gateway to bridge\n' + + ' into the guest (host is fixed to 127.0.0.1). Not enclave support.' + ) // -- Container Configuration -- .option( diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 57537316c..db452b1fe 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -337,9 +337,14 @@ function buildAppleContainerConfig( 'appleContainerMemory', 'appleContainerInitImage', 'appleContainerCli', + 'appleContainerMcpGatewayUpstreamPort', ].some((key) => options[key] !== undefined); if (!selected && !configured) return undefined; + const mcpGatewayUpstreamPort = parseAppleContainerMcpGatewayUpstreamPort( + options.appleContainerMcpGatewayUpstreamPort, + ); + return { previewEnabled: options.appleContainerPreview === true, cpus: parsePositiveIntegerOption( @@ -350,9 +355,38 @@ function buildAppleContainerConfig( memory: parseAppleContainerMemory(options.appleContainerMemory), initImage: options.appleContainerInitImage as string | undefined, cliPath: options.appleContainerCli as string | undefined, + ...(mcpGatewayUpstreamPort === undefined ? {} : { mcpGatewayUpstreamPort }), }; } +/** + * Validates `--apple-container-mcp-gateway-upstream-port` at parse time. + * + * Only the port is accepted; the upstream host is fixed to `127.0.0.1` by the + * infrastructure planner, so this option cannot be used to point a guest + * capability at an arbitrary address. The value is a TCP port, so anything + * outside 1..65535 (including `0`, floats, and non-numeric text) is refused + * here rather than surfacing later as a relay that dials nothing. + */ +function parseAppleContainerMcpGatewayUpstreamPort(value: unknown): number | undefined { + if (value === undefined) return undefined; + const text = String(value).trim(); + if (!/^[0-9]+$/.test(text)) { + throw new Error( + '--apple-container-mcp-gateway-upstream-port must be an integer TCP port in 1..65535; ' + + `got ${text}`, + ); + } + const port = Number(text); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new Error( + '--apple-container-mcp-gateway-upstream-port must be an integer TCP port in 1..65535; ' + + `got ${text}`, + ); + } + return port; +} + /** * Validates `--apple-container-memory` at parse time. * diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 1d62ead40..b0cb2945b 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -829,4 +829,37 @@ describe('generateDockerCompose', () => { } }); }); + + describe('Apple Container external MCP gateway', () => { + function appleConfig(mcpGatewayUpstreamPort?: number): WrapperConfig { + return { + ...mockConfig, + containerRuntime: 'apple-container', + networkIsolation: true, + appleContainer: { + previewEnabled: true, + cpus: 4, + memory: '8G', + ...(mcpGatewayUpstreamPort === undefined ? {} : { mcpGatewayUpstreamPort }), + }, + } as unknown as WrapperConfig; + } + + it('generates no service and publishes no port for the external gateway', () => { + const result = generateDockerCompose(appleConfig(9100), mockNetworkConfig); + + // gh-aw owns `awmg-mcpg`; AWF must neither create it nor look for it. + expect(Object.keys(result.services)).not.toContain('awmg-mcpg'); + const published = Object.values(result.services) + .flatMap((service) => (service.ports ?? []) as string[]); + expect(published.some((entry) => entry.includes('9100'))).toBe(false); + expect(published).toContain('127.0.0.1:3128:3128'); + }); + + it('produces the same compose output as when the option is absent', () => { + const withPort = generateDockerCompose(appleConfig(9100), mockNetworkConfig); + const withoutPort = generateDockerCompose(appleConfig(), mockNetworkConfig); + expect(JSON.stringify(withPort.services)).toBe(JSON.stringify(withoutPort.services)); + }); + }); }); diff --git a/src/config-file.ts b/src/config-file.ts index 99c9ba64b..c350364b7 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -160,6 +160,12 @@ export interface AwfFileConfig { memory?: string; initImage?: string; cliPath?: string; + /** + * Host loopback port of an externally started ordinary MCP gateway (for + * example gh-aw's `awmg-mcpg`). AWF relays `127.0.0.1:` into the + * guest's `mcp-gateway` capability; the host is fixed to loopback. + */ + mcpGatewayUpstreamPort?: number; }; chroot?: { binariesSourcePath?: string; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 53ab47510..00f5dd18f 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -135,6 +135,9 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record