Skip to content

Commit 52969fc

Browse files
CopilotlpcoxCopilot
authored
Add digest-aware AWF runtime image pinning via image-tag metadata (#2086)
* Initial plan * feat: add digest-aware runtime image tag metadata Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7e6bdecd-3b0f-43b9-b54e-22e09e83c585 * chore: clarify setup action digest fallback warning Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7e6bdecd-3b0f-43b9-b54e-22e09e83c585 * chore: polish digest validation messages Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7e6bdecd-3b0f-43b9-b54e-22e09e83c585 * chore: harden digest parsing robustness Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7e6bdecd-3b0f-43b9-b54e-22e09e83c585 * Update docs/github_actions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/usage.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/cli.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f447948 commit 52969fc

10 files changed

Lines changed: 228 additions & 32 deletions

File tree

.github/workflows/test-action.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ jobs:
6060
echo "::error::Version mismatch! Expected v0.7.0, got ${{ steps.setup-awf.outputs.version }}"
6161
exit 1
6262
fi
63-
# Verify image tag is set correctly (without 'v' prefix)
64-
if [[ "${{ steps.setup-awf.outputs.image-tag }}" != "0.7.0" ]]; then
65-
echo "::error::Image tag mismatch! Expected 0.7.0, got ${{ steps.setup-awf.outputs.image-tag }}"
63+
# Verify image tag metadata starts with the expected base tag (without 'v' prefix)
64+
# and may include optional digest metadata entries.
65+
if [[ "${{ steps.setup-awf.outputs.image-tag }}" != 0.7.0* ]]; then
66+
echo "::error::Image tag metadata mismatch! Expected prefix 0.7.0, got ${{ steps.setup-awf.outputs.image-tag }}"
6667
exit 1
6768
fi
6869

action.yml

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ outputs:
2020
description: 'The version of awf that was installed'
2121
value: ${{ steps.install.outputs.version }}
2222
image-tag:
23-
description: 'The image tag that matches the installed version (without the v prefix)'
23+
description: 'The image tag metadata for awf runtime images (base tag plus optional per-image digests)'
2424
value: ${{ steps.install.outputs.image_tag }}
2525

2626
runs:
@@ -99,14 +99,15 @@ runs:
9999
100100
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
101101
102-
# Extract image tag (version without 'v' prefix)
103-
IMAGE_TAG="${VERSION#v}"
104-
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
105-
106102
# Download URLs
107103
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
108104
BINARY_URL="${BASE_URL}/${BINARY_NAME}"
109105
CHECKSUMS_URL="${BASE_URL}/checksums.txt"
106+
CONTAINERS_URL="${BASE_URL}/containers.txt"
107+
108+
# Extract image tag (version without 'v' prefix), then augment with digest pins when available
109+
IMAGE_TAG="${VERSION#v}"
110+
IMAGE_TAG_WITH_DIGESTS="$IMAGE_TAG"
110111
111112
# Download binary
112113
echo "Downloading awf ${VERSION}..."
@@ -122,6 +123,43 @@ runs:
122123
exit 1
123124
fi
124125
126+
# Download optional containers digest manifest
127+
echo "Downloading containers manifest (optional)..."
128+
if curl -fsSL "$CONTAINERS_URL" -o "$INSTALL_DIR/containers.txt"; then
129+
extract_digest() {
130+
local image_name="$1"
131+
grep -E "^ghcr\\.io/${REPO}/${image_name}@sha256:[a-fA-F0-9]{64}$" "$INSTALL_DIR/containers.txt" \
132+
| sed -E 's#.*@(sha256:[a-fA-F0-9]{64})#\1#' \
133+
| tr '[:upper:]' '[:lower:]' \
134+
| head -n 1
135+
}
136+
137+
DIGEST_ENTRIES=()
138+
SQUID_DIGEST="$(extract_digest squid || true)"
139+
AGENT_DIGEST="$(extract_digest agent || true)"
140+
AGENT_ACT_DIGEST="$(extract_digest agent-act || true)"
141+
API_PROXY_DIGEST="$(extract_digest api-proxy || true)"
142+
CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)"
143+
144+
[ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}")
145+
[ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}")
146+
[ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}")
147+
[ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}")
148+
[ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}")
149+
150+
if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then
151+
DIGEST_CSV="$(IFS=,; echo "${DIGEST_ENTRIES[*]}")"
152+
IMAGE_TAG_WITH_DIGESTS="${IMAGE_TAG},${DIGEST_CSV}"
153+
echo "Discovered digest pins for ${#DIGEST_ENTRIES[@]} image(s)"
154+
else
155+
echo "::warning::containers.txt downloaded but no valid digest entries matched ghcr.io/${REPO}/<image>@sha256:<digest>; falling back to tag-only image metadata."
156+
fi
157+
else
158+
echo "::warning::No containers.txt found for ${VERSION}; falling back to tag-only image metadata"
159+
fi
160+
161+
echo "image_tag=$IMAGE_TAG_WITH_DIGESTS" >> "$GITHUB_OUTPUT"
162+
125163
# Verify checksum
126164
echo "Verifying SHA256 checksum..."
127165
@@ -166,8 +204,8 @@ runs:
166204
# Make executable
167205
chmod +x "$INSTALL_DIR/awf"
168206
169-
# Clean up checksums file
170-
rm -f "$INSTALL_DIR/checksums.txt"
207+
# Clean up downloaded metadata files
208+
rm -f "$INSTALL_DIR/checksums.txt" "$INSTALL_DIR/containers.txt"
171209
172210
# Add to PATH
173211
echo "$INSTALL_DIR" >> "$GITHUB_PATH"
@@ -184,20 +222,30 @@ runs:
184222
set -euo pipefail
185223
186224
REGISTRY="ghcr.io/github/gh-aw-firewall"
225+
226+
BASE_TAG="${IMAGE_TAG%%,*}"
227+
extract_digest_from_tag() {
228+
local key="$1"
229+
echo "$IMAGE_TAG" | tr ',' '\n' | grep -E "^${key}=sha256:[a-fA-F0-9]{64}$" | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]' | head -n 1
230+
}
231+
SQUID_DIGEST="$(extract_digest_from_tag squid || true)"
232+
AGENT_DIGEST="$(extract_digest_from_tag agent || true)"
233+
SQUID_REF="${REGISTRY}/squid:${BASE_TAG}${SQUID_DIGEST:+@${SQUID_DIGEST}}"
234+
AGENT_REF="${REGISTRY}/agent:${BASE_TAG}${AGENT_DIGEST:+@${AGENT_DIGEST}}"
187235
188236
echo "Pulling awf Docker images with tag: ${IMAGE_TAG}"
189237
190238
# Pull squid image
191-
echo "Pulling ${REGISTRY}/squid:${IMAGE_TAG}..."
192-
if ! docker pull "${REGISTRY}/squid:${IMAGE_TAG}"; then
193-
echo "::warning::Failed to pull squid image with tag ${IMAGE_TAG}, trying 'latest'"
239+
echo "Pulling ${SQUID_REF}..."
240+
if ! docker pull "${SQUID_REF}"; then
241+
echo "::warning::Failed to pull squid image ${SQUID_REF}, trying 'latest'"
194242
docker pull "${REGISTRY}/squid:latest"
195243
fi
196244
197245
# Pull agent image
198-
echo "Pulling ${REGISTRY}/agent:${IMAGE_TAG}..."
199-
if ! docker pull "${REGISTRY}/agent:${IMAGE_TAG}"; then
200-
echo "::warning::Failed to pull agent image with tag ${IMAGE_TAG}, trying 'latest'"
246+
echo "Pulling ${AGENT_REF}..."
247+
if ! docker pull "${AGENT_REF}"; then
248+
echo "::warning::Failed to pull agent image ${AGENT_REF}, trying 'latest'"
201249
docker pull "${REGISTRY}/agent:latest"
202250
fi
203251

docs/github_actions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The action:
3636
| Output | Description |
3737
|--------|-------------|
3838
| `version` | The version that was installed (e.g., `v0.7.0`) |
39-
| `image-tag` | The image tag matching the version (e.g., `0.7.0`) |
39+
| `image-tag` | Image tag metadata for runtime containers. Format: `0.7.0` or `0.7.0,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,agent-act=sha256:...,cli-proxy=sha256:...`. Supported digest keys currently include `squid`, `agent`, `api-proxy`, `agent-act`, and `cli-proxy`; additional keys may appear in future releases. |
4040

4141
#### Pinning Docker Image Versions
4242

docs/usage.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ Options:
3636
ghcr.io/catthehacker/ubuntu:full-XX.XX
3737
--image-registry <registry> Container image registry (default: ghcr.io/github/gh-aw-firewall)
3838
--image-tag <tag> Container image tag (default: latest)
39-
Image name varies by --agent-image preset:
40-
default → agent:<tag>
41-
act → agent-act:<tag>
39+
Optional digest metadata:
40+
<tag>,squid=sha256:...,agent=sha256:...,agent-act=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...
41+
Supported digest metadata keys: squid, agent, agent-act, api-proxy, cli-proxy
42+
Image name varies by --agent-image preset:
43+
default → agent:<tag>
44+
act → agent-act:<tag>
4245
--skip-pull Use local images without pulling from registry (requires images to be
4346
pre-downloaded) (default: false)
4447
-e, --env <KEY=VALUE> Additional environment variables to pass to container (can be

src/cli.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,9 @@ program
13691369
)
13701370
.option(
13711371
'--image-tag <tag>',
1372-
'Container image tag (applies to both squid and agent images)\n' +
1372+
'Container image tag (applies to squid, agent/agent-act, api-proxy, and cli-proxy when enabled)\n' +
1373+
' Optional digest metadata format:\n' +
1374+
' <tag>,squid=sha256:...,agent=sha256:...,agent-act=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...\n' +
13731375
' Image name varies by --agent-image preset:\n' +
13741376
' default → agent:<tag>\n' +
13751377
' act → agent-act:<tag>',
@@ -2270,7 +2272,11 @@ program
22702272
'Container image registry',
22712273
'ghcr.io/github/gh-aw-firewall'
22722274
)
2273-
.option('--image-tag <tag>', 'Container image tag (applies to squid, agent, and api-proxy images)', 'latest')
2275+
.option(
2276+
'--image-tag <tag>',
2277+
'Container image tag. Supports optional digest metadata: <tag>,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...',
2278+
'latest'
2279+
)
22742280
.option(
22752281
'--agent-image <value>',
22762282
'Agent image preset (default, act) or custom image',

src/commands/predownload.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ describe('predownload', () => {
7474
]);
7575
});
7676

77+
it('should append per-image digests from image-tag metadata', () => {
78+
const images = resolveImages({
79+
...defaults,
80+
imageTag: [
81+
'0.25.18',
82+
'squid=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
83+
'agent=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
84+
'api-proxy=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
85+
'cli-proxy=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
86+
].join(','),
87+
enableApiProxy: true,
88+
difcProxy: true,
89+
});
90+
expect(images).toEqual([
91+
'ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
92+
'ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
93+
'ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
94+
'ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.18@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
95+
]);
96+
});
97+
7798
it('should use custom agent image as-is', () => {
7899
const images = resolveImages({ ...defaults, agentImage: 'ubuntu:22.04' });
79100
expect(images).toEqual([
@@ -93,6 +114,12 @@ describe('predownload', () => {
93114
'must not contain whitespace',
94115
);
95116
});
117+
118+
it('should reject invalid image-tag digest metadata', () => {
119+
expect(() =>
120+
resolveImages({ ...defaults, imageTag: '0.25.18,squid=sha256:not-a-real-digest' })
121+
).toThrow('Invalid --image-tag digest');
122+
});
96123
});
97124

98125
describe('predownloadCommand', () => {

src/commands/predownload.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import execa from 'execa';
22
import { logger } from '../logger';
3+
import { buildRuntimeImageRef, parseImageTag } from '../image-tag';
34

45
export interface PredownloadOptions {
56
imageRegistry: string;
@@ -27,16 +28,17 @@ function validateImageReference(image: string): void {
2728
*/
2829
export function resolveImages(options: PredownloadOptions): string[] {
2930
const { imageRegistry, imageTag, agentImage, enableApiProxy } = options;
31+
const parsedImageTag = parseImageTag(imageTag);
3032
const images: string[] = [];
3133

3234
// Always pull squid
33-
images.push(`${imageRegistry}/squid:${imageTag}`);
35+
images.push(buildRuntimeImageRef(imageRegistry, 'squid', parsedImageTag));
3436

3537
// Pull agent image based on preset
3638
const isPreset = agentImage === 'default' || agentImage === 'act';
3739
if (isPreset) {
3840
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
39-
images.push(`${imageRegistry}/${imageName}:${imageTag}`);
41+
images.push(buildRuntimeImageRef(imageRegistry, imageName, parsedImageTag));
4042
} else {
4143
// Custom image - validate and pull as-is
4244
validateImageReference(agentImage);
@@ -45,12 +47,12 @@ export function resolveImages(options: PredownloadOptions): string[] {
4547

4648
// Optionally pull api-proxy
4749
if (enableApiProxy) {
48-
images.push(`${imageRegistry}/api-proxy:${imageTag}`);
50+
images.push(buildRuntimeImageRef(imageRegistry, 'api-proxy', parsedImageTag));
4951
}
5052

5153
// Optionally pull cli-proxy (mcpg is now started externally by the compiler)
5254
if (options.difcProxy) {
53-
images.push(`${imageRegistry}/cli-proxy:${imageTag}`);
55+
images.push(buildRuntimeImageRef(imageRegistry, 'cli-proxy', parsedImageTag));
5456
}
5557

5658
return images;

src/docker-manager.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,37 @@ describe('docker-manager', () => {
486486
expect(result.services.agent.build).toBeUndefined();
487487
});
488488

489+
it('should append per-image digests from image-tag metadata', () => {
490+
const customConfig = {
491+
...mockConfig,
492+
enableApiProxy: true,
493+
imageTag: [
494+
'v1.0.0',
495+
'squid=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
496+
'agent=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
497+
'api-proxy=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
498+
].join(','),
499+
};
500+
const networkWithProxy = {
501+
...mockNetworkConfig,
502+
proxyIp: '172.30.0.30',
503+
};
504+
const result = generateDockerCompose(customConfig, networkWithProxy);
505+
506+
expect(result.services['squid-proxy'].image).toBe(
507+
'ghcr.io/github/gh-aw-firewall/squid:v1.0.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
508+
);
509+
expect(result.services.agent.image).toBe(
510+
'ghcr.io/github/gh-aw-firewall/agent:v1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
511+
);
512+
expect(result.services['iptables-init'].image).toBe(
513+
'ghcr.io/github/gh-aw-firewall/agent:v1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
514+
);
515+
expect(result.services['api-proxy'].image).toBe(
516+
'ghcr.io/github/gh-aw-firewall/api-proxy:v1.0.0@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
517+
);
518+
});
519+
489520
it('should build locally with custom catthehacker full image', () => {
490521
const customConfig = {
491522
...mockConfig,

src/docker-manager.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { generateSquidConfig, generatePolicyManifest } from './squid-config';
99
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';
1010
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
1111
import { PROXY_ENV_VARS } from './upstream-proxy';
12+
import { parseImageTag, buildRuntimeImageRef } from './image-tag';
1213

1314
const SQUID_PORT = 3128;
1415

@@ -618,7 +619,7 @@ export function generateDockerCompose(
618619
// Default to GHCR images unless buildLocal is explicitly set
619620
const useGHCR = !config.buildLocal;
620621
const registry = config.imageRegistry || 'ghcr.io/github/gh-aw-firewall';
621-
const tag = config.imageTag || 'latest';
622+
const parsedImageTag = parseImageTag(config.imageTag || 'latest');
622623

623624
// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
624625
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;
@@ -726,7 +727,7 @@ export function generateDockerCompose(
726727
// Use GHCR image or build locally
727728
// For SSL Bump, we always build locally to include OpenSSL tools
728729
if (useGHCR && !config.sslBump) {
729-
squidService.image = `${registry}/squid:${tag}`;
730+
squidService.image = buildRuntimeImageRef(registry, 'squid', parsedImageTag);
730731
} else {
731732
squidService.build = {
732733
context: path.join(projectRoot, 'containers/squid'),
@@ -1590,8 +1591,8 @@ export function generateDockerCompose(
15901591
// Use pre-built GHCR image for preset images
15911592
// The GHCR images already have the necessary setup for chroot mode
15921593
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
1593-
agentService.image = `${registry}/${imageName}:${tag}`;
1594-
logger.debug(`Using GHCR image ${imageName}:${tag}`);
1594+
agentService.image = buildRuntimeImageRef(registry, imageName, parsedImageTag);
1595+
logger.debug(`Using GHCR image ${agentService.image}`);
15951596
} else if (config.buildLocal || !isPreset) {
15961597
// Build locally when:
15971598
// 1. --build-local is explicitly specified, OR
@@ -1781,7 +1782,7 @@ export function generateDockerCompose(
17811782

17821783
// Use GHCR image or build locally
17831784
if (useGHCR) {
1784-
proxyService.image = `${registry}/api-proxy:${tag}`;
1785+
proxyService.image = buildRuntimeImageRef(registry, 'api-proxy', parsedImageTag);
17851786
} else {
17861787
proxyService.build = {
17871788
context: path.join(projectRoot, 'containers/api-proxy'),
@@ -1993,7 +1994,7 @@ export function generateDockerCompose(
19931994

19941995
// Use GHCR image or build locally for the Node.js HTTP server container
19951996
if (useGHCR) {
1996-
cliProxyService.image = `${registry}/cli-proxy:${tag}`;
1997+
cliProxyService.image = buildRuntimeImageRef(registry, 'cli-proxy', parsedImageTag);
19971998
} else {
19981999
cliProxyService.build = {
19992000
context: path.join(projectRoot, 'containers/cli-proxy'),

0 commit comments

Comments
 (0)