diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8549424ff..efa239e28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,6 +288,113 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/api-proxy@${{ steps.build_api_proxy.outputs.digest }} + build-router: + name: Build Router Image + runs-on: ubuntu-latest + needs: bump-version + outputs: + digest: ${{ steps.build_router.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + ref: ${{ needs.bump-version.outputs.version }} + + # The image version label is the router's own __version__ (the Dockerfile + # default), not the AWF version, so VERSION is deliberately not passed. + # VCS_REF is the checked-out tag commit, not github.sha: bump-version + # creates a tag-only version commit on top of the dispatched commit. + - name: Compute router build arguments + id: router_args + run: | + set -euo pipefail + echo "vcs_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "openapi_sha256=$(sha256sum containers/router/openapi.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: arm64 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Build and push Router image + id: build_router + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./containers/router + push: true + pull: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/router:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/router:latest + build-args: | + VCS_REF=${{ steps.router_args.outputs.vcs_ref }} + OPENAPI_SHA256=${{ steps.router_args.outputs.openapi_sha256 }} + cache-from: type=gha,scope=router + cache-to: type=gha,mode=max,scope=router + + - name: Verify Router image labels and platforms + env: + REFERENCE: ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }} + EXPECTED_SOURCE: ${{ github.server_url }}/${{ github.repository }} + EXPECTED_REVISION: ${{ steps.router_args.outputs.vcs_ref }} + EXPECTED_OPENAPI_SHA256: ${{ steps.router_args.outputs.openapi_sha256 }} + run: | + set -euo pipefail + docker pull --quiet "$REFERENCE" + labels=$(docker image inspect "$REFERENCE" --format '{{json .Config.Labels}}') + require() { + value=$(jq -r --arg key "$1" '.[$key] // ""' <<<"$labels") + if [[ "$value" != "$2" ]]; then + echo "::error::Router label $1 is '$value', expected '$2'" + exit 1 + fi + } + require org.opencontainers.image.source "$EXPECTED_SOURCE" + require org.opencontainers.image.revision "$EXPECTED_REVISION" + require io.github.gh-aw-router.openapi-sha256 "$EXPECTED_OPENAPI_SHA256" + version=$(jq -r '.["org.opencontainers.image.version"] // ""' <<<"$labels") + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Router version label is '$version', expected a semantic version" + exit 1 + fi + docker buildx imagetools inspect "$REFERENCE" --format '{{json .Manifest}}' \ + | jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | index("linux/amd64") and index("linux/arm64")' > /dev/null \ + || { echo "::error::Router image must include linux/amd64 and linux/arm64"; exit 1; } + echo "Router ${version} at ${REFERENCE}" + + - name: Sign Router image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }} + + - name: Generate SBOM for Router image + uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + with: + image: ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }} + format: spdx-json + output-file: router-sbom.spdx.json + + - name: Attest SBOM for Router image + run: | + cosign attest --yes \ + --predicate router-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }} + build-cli-proxy: name: Build CLI Proxy Image runs-on: ubuntu-latest @@ -722,7 +829,7 @@ jobs: release: name: Create Release runs-on: ubuntu-latest - needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts, build-nvx-test-artifacts] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-router, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts, build-nvx-test-artifacts] steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -876,6 +983,7 @@ jobs: "ghcr.io/${{ github.repository }}/agent@${{ needs['build-agent'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/agent-act@${{ needs['build-agent-act'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/api-proxy@${{ needs['build-api-proxy'].outputs.digest }}" \ + "ghcr.io/${{ github.repository }}/router@${{ needs['build-router'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-enclaves'].outputs.enclave_script_digest }}" \ "ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-enclaves'].outputs.enclave_agent_digest }}" \ diff --git a/.github/workflows/supply-chain-scan.yml b/.github/workflows/supply-chain-scan.yml index d3ef52621..4eba4239b 100644 --- a/.github/workflows/supply-chain-scan.yml +++ b/.github/workflows/supply-chain-scan.yml @@ -14,7 +14,7 @@ name: Supply Chain Scan # # Blocking vs. report-only: # * BLOCKING gate = "Build and scan PR container images". It rebuilds the -# first-party images (agent, api-proxy, cli-proxy, gh-aw-node) from the +# first-party images (agent, api-proxy, router, cli-proxy, gh-aw-node) from the # Dockerfiles in the PR and fails on any High/Critical CVE. This is the # only step that reflects the changes in a PR, so it is the one that gates merges. # * REPORT-ONLY = the Grype and Grant scans of the `gh-aw-manifest` digests. @@ -197,12 +197,16 @@ jobs: GRYPE_DB: ${{ runner.temp }}/grype-db run: | python3 - <<'PYEOF' - import os, subprocess, sys + import hashlib, os, subprocess, sys cwd = os.getcwd() db_cache = os.environ['GRYPE_DB'] + # The router build requires the SHA-256 of its OpenAPI contract. + with open('containers/router/openapi.yaml', 'rb') as f: + router_openapi_sha256 = hashlib.sha256(f.read()).hexdigest() containers = [ ('agent', 'containers/agent', []), ('api-proxy', 'containers/api-proxy', []), + ('router', 'containers/router', ['--build-arg', f'OPENAPI_SHA256={router_openapi_sha256}']), ('cli-proxy', 'containers/cli-proxy', []), ('gh-aw-node', 'containers/gh-aw-node', []), ('enclave-script', 'containers', ['-f', 'containers/enclave/Dockerfile', '--target', 'enclave-script']), diff --git a/action.yml b/action.yml index 90345731a..fac3ebde1 100644 --- a/action.yml +++ b/action.yml @@ -139,6 +139,7 @@ runs: AGENT_DIGEST="$(extract_digest agent || true)" AGENT_ACT_DIGEST="$(extract_digest agent-act || true)" API_PROXY_DIGEST="$(extract_digest api-proxy || true)" + ROUTER_DIGEST="$(extract_digest router || true)" CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)" ENCLAVE_SCRIPT_DIGEST="$(extract_digest enclave-script || true)" ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)" @@ -148,6 +149,7 @@ runs: [ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}") [ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}") [ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}") + [ -n "${ROUTER_DIGEST:-}" ] && DIGEST_ENTRIES+=("router=${ROUTER_DIGEST}") [ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}") [ -n "${ENCLAVE_SCRIPT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-script=${ENCLAVE_SCRIPT_DIGEST}") [ -n "${ENCLAVE_AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-agent=${ENCLAVE_AGENT_DIGEST}") diff --git a/containers/api-proxy/fixtures/routing-corpus.js b/containers/api-proxy/fixtures/routing-corpus.js index 57da57c9f..1ed983a5d 100644 --- a/containers/api-proxy/fixtures/routing-corpus.js +++ b/containers/api-proxy/fixtures/routing-corpus.js @@ -1,8 +1,9 @@ 'use strict'; -// Responses recorded from githubnext/gh-aw-router. Refreshing means taking a new -// export from that repo; never edit a case to make a test pass. A case names the -// routing tables it was recorded against, and that repo owns those tables. +// Responses recorded from the router in containers/router. Refreshing means taking a +// new export with its contract exporter (see containers/router/README.md); never edit +// a case to make a test pass. A case names the routing tables it was recorded +// against, and containers/router owns those tables. const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); diff --git a/containers/router/Dockerfile b/containers/router/Dockerfile index 6a62581b1..9280b8748 100644 --- a/containers/router/Dockerfile +++ b/containers/router/Dockerfile @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1.7 -FROM python:3.12.15-slim-trixie@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 +# Alpine, like the other AWF images: Debian slim carries High CVEs that Debian won't fix, +# which the blocking supply-chain scan rejects. +FROM python:3.12.15-alpine3.24@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4 ARG VERSION=0.1.3 ARG VCS_REF=unknown @@ -20,14 +22,11 @@ ENV HOME=/home/gh-aw-router \ PYTHONUNBUFFERED=1 \ GH_AW_ROUTER_ROUTING_TABLES=/routing -RUN apt-get update \ - && apt-get upgrade --yes --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* \ - && groupadd --system --gid 10001 gh-aw-router \ - && useradd --system --uid 10001 --gid gh-aw-router \ - --create-home --home-dir /home/gh-aw-router \ - --shell /usr/sbin/nologin gh-aw-router \ - && mkdir --parents /app/src /routing +RUN apk upgrade --no-cache \ + && addgroup -S -g 10001 gh-aw-router \ + && adduser -S -D -u 10001 -G gh-aw-router \ + -h /home/gh-aw-router -s /sbin/nologin gh-aw-router \ + && mkdir -p /app/src /routing COPY requirements.lock /tmp/requirements.lock RUN python -m pip install \ @@ -43,7 +42,7 @@ RUN python -c "import sys; from gh_aw_router import __version__; sys.exit(0 if s COPY routing /routing # Fails the build unless the label matches the shipped contract, so the label cannot drift from it. COPY openapi.yaml /app/openapi.yaml -RUN printf '%s /app/openapi.yaml\n' "${OPENAPI_SHA256}" | sha256sum --check --strict --quiet +RUN printf '%s /app/openapi.yaml\n' "${OPENAPI_SHA256}" | sha256sum -c -s RUN find /app /routing -type d -exec chmod 0555 {} + \ && find /app /routing -type f -exec chmod 0444 {} + diff --git a/containers/router/README.md b/containers/router/README.md index 410566f3b..95b368657 100644 --- a/containers/router/README.md +++ b/containers/router/README.md @@ -230,6 +230,10 @@ docker build \ The optional `VERSION` build argument must match the package version. A mismatch fails the build rather than publishing image metadata that disagrees with the CLI or service. +Each AWF release publishes the image for `linux/amd64` and `linux/arm64` as +`ghcr.io/github/gh-aw-firewall/router:`, signed with cosign and with an attested +SPDX SBOM. The tag is the AWF version, while the image's version label stays the package version. + The image bundles every published table under `/routing` and serves all six profiles. It runs as user `10001:10001` and needs no credentials or outbound network. This invocation uses a private network, a read-only filesystem, and no published host port. The service listens on port `8737` @@ -305,11 +309,12 @@ OpenAPI text uses LF in the archive, while the external attachment retains its o Ordinary PR CI runs corpus, packaging, and native Linux amd64 Docker checks without provider credentials. Use the exporter above to create a local contract archive. -Publication requires a separate reviewed source and registry authorization. A deployment pin -has the form `/:@sha256:`. +AWF's release workflow, `.github/workflows/release.yml`, publishes the image together with the +other AWF images and lists its digest in the release's `containers.txt`. A deployment pin has +the form `ghcr.io/github/gh-aw-firewall/router:@sha256:`. A local image ID or Docker archive checksum is not that registry manifest digest. Retain supported immutable images and matching contract archives. Deliver security fixes through -supported release updates and tested client pin changes, not replacement bytes under old pins. +new AWF releases and tested client pin changes, not replacement bytes under old pins. ## Development @@ -373,7 +378,7 @@ Use the shared synthetic table and request fixtures in `tests/conftest.py` for g service, and transport tests. Reserve the bundled-table `service` fixture for release-data and example integration checks so ranking refreshes do not affect unrelated behavior tests. -The HTTP contract follows the router release. There is no API-version request field or +The HTTP contract follows the router version. There is no API-version request field or negotiation layer. Review breaking changes with affected consumers, include migration notes, and test the client, immutable image, OpenAPI document, and corpus as one release combination. Keep the table format marker because files and mounts can change independently of the image. @@ -386,8 +391,14 @@ must not derive expectations from the current service. Review classifier prompt contract fixture changes. Invalid non-null classifier output belongs in rejection cases; caller-authorized degradation uses omitted or null classification. +Router changes ship in the next AWF release. Bump the router version in the same pull request +as any change to routing behaviour or the HTTP contract. The version appears in +`pyproject.toml`, `uv.lock`, `src/gh_aw_router/__init__.py`, the Dockerfile's `VERSION` +default, and `openapi.yaml`. + Keep release publication and credentialed attestations in separately reviewed, -explicitly permissioned jobs. Do not execute untrusted PR code through `pull_request_target`. +explicitly permissioned jobs, which today means AWF's `.github/workflows/release.yml`. +Do not execute untrusted PR code through `pull_request_target`. ### Dependencies and routing data @@ -427,6 +438,7 @@ Request size and deadline limits are not a substitute for network isolation, con or proxy timeouts. Context-window estimation is approximate. Frozen Python models do not make nested containers deeply immutable, so in-process callers must not mutate shared routing data. -Report vulnerabilities through the repository's -[security policy](https://github.com/github/gh-aw-firewall/security/policy). The code is licensed +Supported router versions follow AWF releases. Report vulnerabilities through the repository's +[security policy](https://github.com/github/gh-aw-firewall/security/policy), and include the AWF +version and the router version that `GET /capabilities` reports. The code is licensed under [MIT](https://github.com/github/gh-aw-firewall/blob/main/LICENSE). diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index fb8d7b6f5..cc724a24d 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2261,9 +2261,12 @@ require the native `api.openai.com` or `api.anthropic.com` target; AWF does not route custom gateways or translate or forward requests across provider boundaries. A supported routed run also requires a complete `container.images` manifest containing digest-pinned references for `router` -and every other enabled image role. The legacy `latest` router default is kept -only for resolver compatibility and is not a supported tag-only routed -configuration. +and every other enabled image role. Without a manifest, the router image +resolves like the other AWF images, from `--image-registry` and `--image-tag` +(`ghcr.io/github/gh-aw-firewall/router:` by default), and routing still +fails unless that reference is pinned by digest. With `--build-local`, AWF +builds the router from `containers/router` in the same checkout instead, and +no digest pin is required. The candidate pool uses models discovered for the selected native provider. The `/reflect` `model_api_mapping` includes maintained routing metadata for diff --git a/docs/releasing.md b/docs/releasing.md index 75602e960..f07573521 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -92,6 +92,7 @@ Once the workflow completes: - `squid:` and `squid:latest` - `agent:` and `agent:latest` - `api-proxy:` and `api-proxy:latest` + - `router:` and `router:latest` - `cli-proxy:` and `cli-proxy:latest` - `agent-act:` and `agent-act:latest` (GitHub Actions parity image) - `enclave-script:` and `enclave-script:latest` @@ -142,6 +143,7 @@ Docker images are published to `ghcr.io/github/gh-aw-firewall`: - `squid:` and `squid:latest` - Squid proxy container - `agent:` and `agent:latest` - Agent execution environment (minimal, ~200MB) - `api-proxy:` and `api-proxy:latest` - API proxy sidecar for credential isolation +- `router:` and `router:latest` - Model router sidecar for routed runs, built from `containers/router`. Its `org.opencontainers.image.version` label is the router's own version (`__version__` in `containers/router/src/gh_aw_router/__init__.py`), not the AWF version, so the release doesn't pass a `VERSION` build argument - `cli-proxy:` and `cli-proxy:latest` - CLI proxy sidecar for gh CLI access via mcpg DIFC proxy - `agent-act:` and `agent-act:latest` - Agent with GitHub Actions parity (~2GB) - `enclave-script:` and `enclave-script:latest` - No-network script executor, built from `containers/enclave/Dockerfile` target `enclave-script` @@ -180,6 +182,9 @@ pkg . --targets node18-linux-x64 --output release/awf docker build -t awf-test/squid:local ./containers/squid docker build -t awf-test/agent:local ./containers/agent docker build -t awf-test/api-proxy:local ./containers/api-proxy +docker build -t awf-test/router:local \ + --build-arg OPENAPI_SHA256="$(sha256sum containers/router/openapi.yaml | cut -d ' ' -f 1)" \ + ./containers/router docker build -t awf-test/cli-proxy:local ./containers/cli-proxy # Test with local images @@ -222,7 +227,7 @@ If users report that Docker images can't be pulled: To make packages public: 1. Go to repository **Packages** page -2. Click on the package (squid, agent, api-proxy, cli-proxy, or agent-act) +2. Click on the package (squid, agent, api-proxy, router, cli-proxy, or agent-act) 3. Go to **Package settings** 4. Change visibility to **Public** diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index bbc89f937..dcd30ad16 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -2,6 +2,7 @@ import { generateDockerCompose } from './compose-generator'; import { ACT_PRESET_BASE_IMAGE } from './host-identity'; import { WrapperConfig } from './types'; import { baseConfig, mockNetworkConfig } from './test-helpers/docker-test-fixtures.test-utils'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -91,6 +92,43 @@ describe('generateDockerCompose', () => { expect(result.networks['awf-routing']).toMatchObject({ internal: true }); }); + it('builds the router from the checkout with the contract digest under --build-local', () => { + const routingConfig: WrapperConfig = { + ...mockConfig, + buildLocal: true, + enableApiProxy: true, + experimentalModelRouting: true, + 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 routerDir = path.join(__dirname, '..', 'containers', 'router'); + const contractDigest = crypto.createHash('sha256') + .update(fs.readFileSync(path.join(routerDir, 'openapi.yaml'))) + .digest('hex'); + + const router = generateDockerCompose(routingConfig, { + ...mockNetworkConfig, + proxyIp: '172.30.0.30', + }).services.router; + + expect(router.image).toBeUndefined(); + expect(router.build).toEqual({ + context: routerDir, + dockerfile: 'Dockerfile', + args: { OPENAPI_SHA256: contractDigest }, + }); + }); + it('fails routed compose generation when host staging has not happened', () => { const digest = 'a'.repeat(64); const routingConfig: WrapperConfig = { diff --git a/src/image-resolver.test.ts b/src/image-resolver.test.ts index ce8d0ee52..719507a55 100644 --- a/src/image-resolver.test.ts +++ b/src/image-resolver.test.ts @@ -119,13 +119,25 @@ describe('custom runtime image manifest', () => { expect(resolveDohProxyImage(config())).toBe('cloudflare/cloudflared:latest'); }); - it('defaults the router to the published upstream image', () => { + it('defaults the router to the AWF registry and image tag', () => { expect(resolveRuntimeImage( config(), 'router', 'ghcr.io/github/gh-aw-firewall', parseImageTag('latest'), - )).toBe('ghcr.io/githubnext/gh-aw-router:latest'); + )).toBe('ghcr.io/github/gh-aw-firewall/router:latest'); + expect(resolveRuntimeImage( + config(), + 'router', + 'registry.example.com/mirror', + parseImageTag('0.28.61'), + )).toBe('registry.example.com/mirror/router:0.28.61'); + expect(resolveRuntimeImage( + config(), + 'router', + 'ghcr.io/github/gh-aw-firewall', + parseImageTag(`0.28.61,router=${digest}`), + )).toBe(`ghcr.io/github/gh-aw-firewall/router:0.28.61@${digest}`); expect(resolveRuntimeImage( config({ router: image('router') }), 'router', diff --git a/src/image-resolver.ts b/src/image-resolver.ts index b08a9af22..4e7d6407d 100644 --- a/src/image-resolver.ts +++ b/src/image-resolver.ts @@ -38,7 +38,6 @@ const MANIFEST_KEY: Record = { const DEFAULT_IMAGE_REGISTRY = 'ghcr.io/github/gh-aw-firewall'; const DEFAULT_DIND_STAGING_IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:latest'; const DEFAULT_DOH_PROXY_IMAGE = 'cloudflare/cloudflared:latest'; -const DEFAULT_ROUTER_IMAGE = 'ghcr.io/githubnext/gh-aw-router:latest'; /** * Roles whose default (non-manifest) image is not published under the AWF @@ -47,7 +46,6 @@ const DEFAULT_ROUTER_IMAGE = 'ghcr.io/githubnext/gh-aw-router:latest'; const EXTERNAL_DEFAULT_IMAGE: Partial> = { 'doh-proxy': DEFAULT_DOH_PROXY_IMAGE, 'dind-staging': DEFAULT_DIND_STAGING_IMAGE, - router: DEFAULT_ROUTER_IMAGE, }; export interface ResolvedRuntimeImage { diff --git a/src/routing/bootstrap.test.ts b/src/routing/bootstrap.test.ts index 5dddb7893..873add9ca 100644 --- a/src/routing/bootstrap.test.ts +++ b/src/routing/bootstrap.test.ts @@ -120,6 +120,18 @@ describe('routing bootstrap', () => { expect(config.modelRoutingBootstrap).toBeUndefined(); }); + it('accepts a router built from the checkout with --build-local without a digest pin', () => { + const conversationFile = path.join(tempDir, 'conversation.json'); + writeConversation(conversationFile); + const config = { ...makeConfig(path.join(tempDir, 'work'), conversationFile), buildLocal: true }; + delete config.images; + + const state = stageRoutingConversation(config)!; + + expect(config.modelRoutingBootstrap).toBe(state); + cleanupRoutingState(config); + }); + it('refuses routing with preserved containers before staging', () => { const conversationFile = path.join(tempDir, 'conversation.json'); writeConversation(conversationFile); diff --git a/src/routing/bootstrap.ts b/src/routing/bootstrap.ts index ff2434260..54767aa20 100644 --- a/src/routing/bootstrap.ts +++ b/src/routing/bootstrap.ts @@ -132,6 +132,8 @@ function assertRoutingHostSupported(config: WrapperConfig): void { if (exposingMount) { throw new Error('Model routing is not supported when the agent can access the Docker socket'); } + // --build-local builds containers/router from the same checkout as this CLI, so only pulled images need a pin. + if (config.buildLocal) return; const routerImage = resolveRuntimeImageFor(config, 'router'); if (!isDigestPinnedImageReference(routerImage)) { throw new Error('Model routing requires container.images.router to be pinned by digest'); diff --git a/src/services/router-service.ts b/src/services/router-service.ts index ce6dffc82..3a599cc82 100644 --- a/src/services/router-service.ts +++ b/src/services/router-service.ts @@ -1,3 +1,6 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; import { ROUTER_CONTAINER_NAME } from '../constants'; import { assignImageSource } from '../image-tag'; import { buildContainerSecurityHardening } from './service-security'; @@ -47,6 +50,13 @@ export function buildRouterService({ imageConfig }: RouterServiceParams): any { containerDir: 'router', }); if (useGHCR && resolveImage) service.image = resolveImage('router'); + if (service.build) { + // The Dockerfile verifies the shipped contract against this digest, as the release workflow does. + const contract = fs.readFileSync(path.join(service.build.context, 'openapi.yaml')); + service.build.args = { + OPENAPI_SHA256: crypto.createHash('sha256').update(contract).digest('hex'), + }; + } return service; }