Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions containers/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,35 +1,61 @@
FROM ubuntu:22.04
# BASE_IMAGE allows customization of the base Ubuntu image for closer parity
# with GitHub Actions runner environments. Options:
# - ubuntu:22.04 (default): Minimal image, smallest size (~200MB)
# - ghcr.io/catthehacker/ubuntu:runner-22.04: Closer to GitHub Actions runner (~2-5GB)
# - ghcr.io/catthehacker/ubuntu:full-22.04: Near-identical to GitHub Actions runner (~20GB compressed)
# Use --build-arg BASE_IMAGE=<image> to customize
ARG BASE_IMAGE=ubuntu:22.04
FROM ${BASE_IMAGE}

# Install required packages and Node.js 22
# Note: Some packages may already exist in runner-like base images, apt handles this gracefully
RUN apt-get update && \
apt-get install -y --no-install-recommends \
iptables \
curl \
ca-certificates \
git \
gh \
gnupg \
dnsutils \
net-tools \
netcat-openbsd \
gosu \
libcap2-bin && \
# Prefer system binaries over runner toolcache (e.g., act images) for Node checks.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" && \
# Install Node.js 22 from NodeSource
# Remove any existing nodejs packages first to avoid conflicts
apt-get remove -y nodejs npm || true && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
# Verify Node.js 22 was installed correctly
node --version | grep -q "^v22\." || (echo "ERROR: Node.js 22 not installed correctly" && exit 1) && \
npx --version || (echo "ERROR: npx not found" && exit 1) && \
# Check if Node.js 22 is already installed (common in runner images)
if ! command -v node >/dev/null 2>&1 || ! node --version | grep -qE '^v22\.'; then \
# Remove any existing nodejs packages first to avoid conflicts
apt-get remove -y nodejs npm || true && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
# Verify Node.js 22 was installed correctly
node --version | grep -q "^v22\." || (echo "ERROR: Node.js 22 not installed correctly" && exit 1) && \
npx --version || (echo "ERROR: npx not found" && exit 1); \
fi && \
rm -rf /var/lib/apt/lists/*

# Create non-root user with UID/GID matching host user
# This allows the user command to run with appropriate permissions
# and prevents file ownership issues with mounted volumes
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd -g ${USER_GID} awfuser && \
useradd -u ${USER_UID} -g ${USER_GID} -m -s /bin/bash awfuser && \
RUN if ! getent group awfuser >/dev/null 2>&1; then \
if ! getent group ${USER_GID} >/dev/null 2>&1; then \
groupadd -g ${USER_GID} awfuser; \
else \
groupadd awfuser; \
fi; \
fi && \
if ! id -u awfuser >/dev/null 2>&1; then \
if ! getent passwd ${USER_UID} >/dev/null 2>&1; then \
useradd -u ${USER_UID} -g awfuser -m -s /bin/bash awfuser; \
else \
useradd -g awfuser -m -s /bin/bash awfuser; \
fi; \
fi && \
# Create directories for awfuser
mkdir -p /home/awfuser/.copilot/logs && \
chown -R awfuser:awfuser /home/awfuser
Expand Down
99 changes: 99 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Options:
--env-all Pass all host environment variables to container
-v, --mount <path:path> Volume mount (host_path:container_path[:ro|rw])
--tty Allocate a pseudo-TTY for interactive tools
--build-local Build containers locally instead of using GHCR images
--agent-base-image <image> Base image for agent container (requires --build-local)
See "Agent Base Image" section for available options
-V, --version Output the version number
-h, --help Display help for command

Expand Down Expand Up @@ -342,6 +345,102 @@ SSL Bump requires intercepting HTTPS traffic:

For more details, see [SSL Bump documentation](ssl-bump.md).

## Agent Base Image (GitHub Actions Parity)

By default, the agent container uses `ubuntu:22.04`, a minimal image optimized for size (~200MB). When you need closer parity with GitHub Actions runner environments, you can specify an alternative base image.

### Available Base Images

| Image | Size | Description |
|-------|------|-------------|
| `ubuntu:22.04` (default) | ~200MB | Minimal Ubuntu, smallest footprint |
| `ghcr.io/catthehacker/ubuntu:runner-22.04` | ~2-5GB | Medium image with common tools, closer to GitHub Actions |
| `ghcr.io/catthehacker/ubuntu:full-22.04` | ~20GB compressed | Near-identical to GitHub Actions runner |

### Usage

The `--agent-base-image` flag requires `--build-local` since it customizes the container build:

```bash
# Use runner image for better GitHub Actions compatibility
sudo awf \
--build-local \
--agent-base-image ghcr.io/catthehacker/ubuntu:runner-22.04 \
--allow-domains github.com \
-- your-command

# Use full image for maximum parity (large download, ~20GB)
sudo awf \
--build-local \
--agent-base-image ghcr.io/catthehacker/ubuntu:full-22.04 \
--allow-domains github.com \
-- your-command
```

### When to Use Custom Base Images

**Use `ubuntu:22.04` (default) when:**
- Fast startup time is important
- Minimal container size is preferred
- Your commands only need basic tools (curl, git, Node.js, Docker CLI)

**Use `runner-22.04` when:**
- You need tools commonly available in GitHub Actions (multiple Python versions, Go, Java, etc.)
- Commands fail due to missing dependencies
- Moderate GitHub Actions parity is needed

**Use `full-22.04` when:**
- Maximum GitHub Actions parity is required
- You need specific tools only available in the full runner image
- Download time and disk space are not concerns

### Security Considerations

**⚠️ IMPORTANT:** Custom base images introduce supply chain risk. When using third-party images:

1. **Verify image sources** - Only use images from trusted publishers. The `catthehacker` images are community-maintained and not officially supported by GitHub.

2. **Review image contents** - Understand what tools and configurations are included. Third-party images may contain pre-installed software that could behave unexpectedly.

3. **Pin specific versions** - Use image digests (e.g., `@sha256:...`) instead of mutable tags to prevent tag manipulation:
```bash
--agent-base-image ghcr.io/catthehacker/ubuntu@sha256:abc123...
```

4. **Monitor for vulnerabilities** - Third-party images may not receive timely security updates compared to official images.

**Existing security controls remain in effect:**
- Host-level iptables (DOCKER-USER chain) enforce egress filtering regardless of container contents
- Squid proxy enforces domain allowlist at L7
- NET_ADMIN capability is dropped before user command execution
- Seccomp profile blocks dangerous syscalls
- `no-new-privileges` prevents privilege escalation

**For maximum security, use the default `ubuntu:22.04` image.** Custom base images are recommended only when you trust the image publisher and the benefits outweigh the supply chain risks.

### Pre-installed Tools

The default `ubuntu:22.04` image includes:
- Node.js 22
- Docker CLI
- curl, git, iptables
- CA certificates
- Network utilities (dnsutils, net-tools, netcat)

When using runner images, you get additional tools like:
- Multiple Python, Node.js, Go, Ruby versions
- Build tools (make, cmake, gcc)
- AWS CLI, Azure CLI, GitHub CLI
- Container tools (docker, buildx)
- And many more (see [catthehacker/docker_images](https://github.com/catthehacker/docker_images))

### Notes

- Custom base images only work with `--build-local` (not GHCR images)
- First build with a new base image will take longer (downloading the image)
- Subsequent builds use Docker cache and are faster
- The `full-22.04` image requires significant disk space (~60GB extracted)

## Limitations

### No Internationalized Domains
Expand Down
147 changes: 146 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers } from './cli';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentBaseImage } from './cli';
import { redactSecrets } from './redact-secrets';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 18)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -774,4 +774,149 @@
expect(DEFAULT_DNS_SERVERS).toEqual(['8.8.8.8', '8.8.4.4']);
});
});

describe('validateAgentBaseImage', () => {
describe('valid images', () => {
it('should accept official Ubuntu images', () => {
expect(validateAgentBaseImage('ubuntu:22.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ubuntu:24.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ubuntu:20.04')).toEqual({ valid: true });
});

it('should accept catthehacker runner images', () => {
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-22.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-24.04')).toEqual({ valid: true });
});

it('should accept catthehacker full images', () => {
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-22.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-24.04')).toEqual({ valid: true });
});

it('should accept catthehacker act images', () => {
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-24.04')).toEqual({ valid: true });
});

it('should accept images with SHA256 digest pinning', () => {
expect(validateAgentBaseImage('ubuntu:22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
});
});

describe('invalid images', () => {
it('should reject arbitrary images', () => {
const result = validateAgentBaseImage('malicious-registry.com/evil:latest');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject images with typos', () => {
const result = validateAgentBaseImage('ubunto:22.04');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject non-ubuntu official images', () => {
const result = validateAgentBaseImage('alpine:latest');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject unknown registries', () => {
const result = validateAgentBaseImage('docker.io/library/ubuntu:22.04');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject images from other catthehacker registries', () => {
const result = validateAgentBaseImage('ghcr.io/catthehacker/debian:latest');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject ubuntu with non-standard tags', () => {
const result = validateAgentBaseImage('ubuntu:latest');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject empty image string', () => {
const result = validateAgentBaseImage('');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject ubuntu with only major version', () => {
const result = validateAgentBaseImage('ubuntu:22');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject catthehacker with wrong prefix', () => {
const result = validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:minimal-22.04');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject malformed SHA256 digest (too short)', () => {
const result = validateAgentBaseImage('ubuntu:22.04@sha256:abc123');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should reject image with path traversal attempt', () => {
const result = validateAgentBaseImage('../ubuntu:22.04');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid base image');
});

it('should provide helpful error message with allowed options', () => {
const result = validateAgentBaseImage('invalid:image');
expect(result.valid).toBe(false);
expect(result.error).toContain('ubuntu:XX.XX');
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:runner-XX.XX');
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:full-XX.XX');
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:act-XX.XX');
expect(result.error).toContain('@sha256:');
});
});

describe('regex pattern coverage', () => {
// Ensure each regex pattern in SAFE_BASE_IMAGE_PATTERNS is individually tested
it('should match pattern 1: plain ubuntu version', () => {
expect(validateAgentBaseImage('ubuntu:18.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ubuntu:26.10')).toEqual({ valid: true });
});

it('should match pattern 2: catthehacker runner/full/act without digest', () => {
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-18.04')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-26.10')).toEqual({ valid: true });
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04')).toEqual({ valid: true });
});

it('should match pattern 3: catthehacker with SHA256 digest', () => {
const digest = 'sha256:' + '1234567890abcdef'.repeat(4);
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:runner-22.04@${digest}`)).toEqual({ valid: true });
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:full-24.04@${digest}`)).toEqual({ valid: true });
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:act-22.04@${digest}`)).toEqual({ valid: true });
});

it('should match pattern 4: plain ubuntu with SHA256 digest', () => {
const digest = 'sha256:' + 'abcdef1234567890'.repeat(4);
expect(validateAgentBaseImage(`ubuntu:22.04@${digest}`)).toEqual({ valid: true });
expect(validateAgentBaseImage(`ubuntu:24.04@${digest}`)).toEqual({ valid: true });
});

it('should reject images that almost match but do not exactly', () => {
// Nearly matching but invalid
expect(validateAgentBaseImage('ubuntu:22.04 ').valid).toBe(false); // trailing space
expect(validateAgentBaseImage(' ubuntu:22.04').valid).toBe(false); // leading space
expect(validateAgentBaseImage('Ubuntu:22.04').valid).toBe(false); // capital U
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:Runner-22.04').valid).toBe(false); // capital R
});
});
});
});
Loading
Loading