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
26 changes: 25 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, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout } from './cli';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } 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 / ESLint

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
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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

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 / ESLint

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

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -1881,4 +1881,28 @@
expect(result).toBe(' --flag');
});
});

describe('handlePredownloadAction', () => {
it('should delegate to predownloadCommand with correct options', async () => {
// Mock the predownload module that handlePredownloadAction dynamically imports
const mockPredownloadCommand = jest.fn().mockResolvedValue(undefined);
jest.mock('./commands/predownload', () => ({
predownloadCommand: mockPredownloadCommand,
}));

await handlePredownloadAction({
imageRegistry: 'ghcr.io/test',
imageTag: 'v1.0',
agentImage: 'default',
enableApiProxy: false,
});

expect(mockPredownloadCommand).toHaveBeenCalledWith({
imageRegistry: 'ghcr.io/test',
imageTag: 'v1.0',
agentImage: 'default',
enableApiProxy: false,
});
});
});
});
39 changes: 39 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,45 @@ export function validateFormat(format: string, validFormats: string[]): void {
}
}

// Predownload action handler - exported for testing
export async function handlePredownloadAction(options: {
imageRegistry: string;
imageTag: string;
agentImage: string;
enableApiProxy: boolean;
}): Promise<void> {
const { predownloadCommand } = await import('./commands/predownload');
try {
await predownloadCommand({
imageRegistry: options.imageRegistry,
imageTag: options.imageTag,
agentImage: options.agentImage,
enableApiProxy: options.enableApiProxy,
});
} catch (error) {
const exitCode = (error as Error & { exitCode?: number }).exitCode ?? 1;
process.exit(exitCode);
}
}

// Predownload subcommand - pre-pull container images
program
.command('predownload')
.description('Pre-download Docker images for offline use or faster startup')
.option(
'--image-registry <registry>',
'Container image registry',
'ghcr.io/github/gh-aw-firewall'
)
.option('--image-tag <tag>', 'Container image tag (applies to squid, agent, and api-proxy images)', 'latest')
.option(
'--agent-image <value>',
'Agent image preset (default, act) or custom image',
'default'
)
.option('--enable-api-proxy', 'Also download the API proxy image', false)
.action(handlePredownloadAction);

// Logs subcommand - view Squid proxy logs
const logsCmd = program
.command('logs')
Expand Down
156 changes: 156 additions & 0 deletions src/commands/predownload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { resolveImages, predownloadCommand, PredownloadOptions } from './predownload';

// Mock execa
jest.mock('execa', () => {
const mockExeca = jest.fn().mockResolvedValue({ stdout: '', stderr: '' });
return { __esModule: true, default: mockExeca };
});

// eslint-disable-next-line @typescript-eslint/no-require-imports
const execa = require('execa').default as jest.Mock;

describe('predownload', () => {
describe('resolveImages', () => {
const defaults: PredownloadOptions = {
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
imageTag: 'latest',
agentImage: 'default',
enableApiProxy: false,
};

it('should resolve squid and default agent images', () => {
const images = resolveImages(defaults);
expect(images).toEqual([
'ghcr.io/github/gh-aw-firewall/squid:latest',
'ghcr.io/github/gh-aw-firewall/agent:latest',
]);
});

it('should resolve agent-act image for act preset', () => {
const images = resolveImages({ ...defaults, agentImage: 'act' });
expect(images).toEqual([
'ghcr.io/github/gh-aw-firewall/squid:latest',
'ghcr.io/github/gh-aw-firewall/agent-act:latest',
]);
});

it('should include api-proxy when enabled', () => {
const images = resolveImages({ ...defaults, enableApiProxy: true });
expect(images).toEqual([
'ghcr.io/github/gh-aw-firewall/squid:latest',
'ghcr.io/github/gh-aw-firewall/agent:latest',
'ghcr.io/github/gh-aw-firewall/api-proxy:latest',
]);
});

it('should use custom registry and tag', () => {
const images = resolveImages({
...defaults,
imageRegistry: 'my-registry.io/awf',
imageTag: 'v1.0.0',
});
expect(images).toEqual([
'my-registry.io/awf/squid:v1.0.0',
'my-registry.io/awf/agent:v1.0.0',
]);
});

it('should use custom agent image as-is', () => {
const images = resolveImages({ ...defaults, agentImage: 'ubuntu:22.04' });
expect(images).toEqual([
'ghcr.io/github/gh-aw-firewall/squid:latest',
'ubuntu:22.04',
]);
});

it('should reject custom image starting with dash', () => {
expect(() => resolveImages({ ...defaults, agentImage: '--help' })).toThrow(
'must not start with "-"',
);
});

it('should reject custom image containing whitespace', () => {
expect(() => resolveImages({ ...defaults, agentImage: 'ubuntu 22.04' })).toThrow(
'must not contain whitespace',
);
});
});

describe('predownloadCommand', () => {
const defaults: PredownloadOptions = {
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
imageTag: 'latest',
agentImage: 'default',
enableApiProxy: false,
};

beforeEach(() => {
execa.mockReset();
execa.mockResolvedValue({ stdout: '', stderr: '' });
});

it('should pull all resolved images', async () => {
await predownloadCommand(defaults);

expect(execa).toHaveBeenCalledTimes(2);
expect(execa).toHaveBeenCalledWith(
'docker',
['pull', 'ghcr.io/github/gh-aw-firewall/squid:latest'],
{ stdio: 'inherit' },
);
expect(execa).toHaveBeenCalledWith(
'docker',
['pull', 'ghcr.io/github/gh-aw-firewall/agent:latest'],
{ stdio: 'inherit' },
);
});

it('should pull api-proxy when enabled', async () => {
await predownloadCommand({ ...defaults, enableApiProxy: true });

expect(execa).toHaveBeenCalledTimes(3);
expect(execa).toHaveBeenCalledWith(
'docker',
['pull', 'ghcr.io/github/gh-aw-firewall/api-proxy:latest'],
{ stdio: 'inherit' },
);
});

it('should throw with exitCode 1 when a pull fails', async () => {
execa
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('pull failed'));

try {
await predownloadCommand(defaults);
fail('Expected predownloadCommand to throw');
} catch (error) {
expect((error as Error).message).toBe('1 of 2 image(s) failed to pull');
expect((error as Error & { exitCode?: number }).exitCode).toBe(1);
}
});

it('should continue pulling remaining images after a failure', async () => {
execa.mockRejectedValueOnce(new Error('pull failed')).mockResolvedValueOnce({ stdout: '', stderr: '' });

await expect(predownloadCommand(defaults)).rejects.toThrow(
'1 of 2 image(s) failed to pull',
);

// Both images should have been attempted
expect(execa).toHaveBeenCalledTimes(2);
});

it('should handle non-Error rejection', async () => {
execa.mockRejectedValueOnce('string error').mockResolvedValueOnce({ stdout: '', stderr: '' });

try {
await predownloadCommand(defaults);
fail('Expected predownloadCommand to throw');
} catch (error) {
expect((error as Error).message).toBe('1 of 2 image(s) failed to pull');
expect((error as Error & { exitCode?: number }).exitCode).toBe(1);
}
});
});
});
83 changes: 83 additions & 0 deletions src/commands/predownload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import execa from 'execa';
import { logger } from '../logger';

export interface PredownloadOptions {
imageRegistry: string;
imageTag: string;
agentImage: string;
enableApiProxy: boolean;
}

/**
* Validates a custom Docker image reference.
* Rejects values that could be interpreted as Docker CLI flags or contain whitespace.
*/
function validateImageReference(image: string): void {
if (image.startsWith('-')) {
throw new Error(`Invalid image reference "${image}": must not start with "-"`);
}
if (/\s/.test(image)) {
throw new Error(`Invalid image reference "${image}": must not contain whitespace`);
}
}

/**
* Resolves the list of image references to pull based on the given options.
*/
export function resolveImages(options: PredownloadOptions): string[] {
const { imageRegistry, imageTag, agentImage, enableApiProxy } = options;
const images: string[] = [];

// Always pull squid
images.push(`${imageRegistry}/squid:${imageTag}`);

// Pull agent image based on preset
const isPreset = agentImage === 'default' || agentImage === 'act';
if (isPreset) {
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
images.push(`${imageRegistry}/${imageName}:${imageTag}`);
} else {
Comment on lines +35 to +39
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveImages re-implements the same preset-to-image mapping logic that already exists in generateDockerCompose (e.g., default→agent, act→agent-act). This duplication is likely to drift as image naming/registry/tag logic evolves (including api-proxy behavior). Consider extracting a shared image-resolution helper used by both generateDockerCompose and this command so the pulled images always match what runtime would use.

Copilot uses AI. Check for mistakes.
// Custom image - validate and pull as-is
validateImageReference(agentImage);
images.push(agentImage);
}

// Optionally pull api-proxy
if (enableApiProxy) {
images.push(`${imageRegistry}/api-proxy:${imageTag}`);
}

return images;
}

/**
* Pre-download Docker images for offline use or faster startup.
*/
export async function predownloadCommand(options: PredownloadOptions): Promise<void> {
const images = resolveImages(options);

logger.info(`Pre-downloading ${images.length} image(s)...`);

let failed = 0;
for (const image of images) {
logger.info(`Pulling ${image}...`);
try {
await execa('docker', ['pull', image], { stdio: 'inherit' });
logger.info(`Successfully pulled ${image}`);
} catch (error) {
logger.error(`Failed to pull ${image}: ${error instanceof Error ? error.message : error}`);
failed++;
}
}

if (failed > 0) {
const message = `${failed} of ${images.length} image(s) failed to pull`;
logger.error(message);
const error: Error & { exitCode?: number } = new Error(message);
error.exitCode = 1;
throw error;
}

logger.info(`All ${images.length} image(s) pre-downloaded successfully`);
logger.info('You can now use --skip-pull to skip pulling images at runtime');
}
Loading