Skip to content

Commit c17c9ff

Browse files
Mossakaclaude
andcommitted
fix: address review feedback on predownload command
- Validate custom agentImage to reject leading dashes and whitespace - Replace process.exit(1) with thrown error with exitCode for testability - Update --image-tag help text to clarify it applies to all images - Add tests for image reference validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d2c3f2a commit c17c9ff

File tree

3 files changed

+60
-34
lines changed

3 files changed

+60
-34
lines changed

src/cli.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,12 +1490,17 @@ export async function handlePredownloadAction(options: {
14901490
enableApiProxy: boolean;
14911491
}): Promise<void> {
14921492
const { predownloadCommand } = await import('./commands/predownload');
1493-
await predownloadCommand({
1494-
imageRegistry: options.imageRegistry,
1495-
imageTag: options.imageTag,
1496-
agentImage: options.agentImage,
1497-
enableApiProxy: options.enableApiProxy,
1498-
});
1493+
try {
1494+
await predownloadCommand({
1495+
imageRegistry: options.imageRegistry,
1496+
imageTag: options.imageTag,
1497+
agentImage: options.agentImage,
1498+
enableApiProxy: options.enableApiProxy,
1499+
});
1500+
} catch (error) {
1501+
const exitCode = (error as Error & { exitCode?: number }).exitCode ?? 1;
1502+
process.exit(exitCode);
1503+
}
14991504
}
15001505

15011506
// Predownload subcommand - pre-pull container images
@@ -1507,7 +1512,7 @@ program
15071512
'Container image registry',
15081513
'ghcr.io/github/gh-aw-firewall'
15091514
)
1510-
.option('--image-tag <tag>', 'Container image tag', 'latest')
1515+
.option('--image-tag <tag>', 'Container image tag (applies to squid, agent, and api-proxy images)', 'latest')
15111516
.option(
15121517
'--agent-image <value>',
15131518
'Agent image preset (default, act) or custom image',

src/commands/predownload.test.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ describe('predownload', () => {
6262
'ubuntu:22.04',
6363
]);
6464
});
65+
66+
it('should reject custom image starting with dash', () => {
67+
expect(() => resolveImages({ ...defaults, agentImage: '--help' })).toThrow(
68+
'must not start with "-"',
69+
);
70+
});
71+
72+
it('should reject custom image containing whitespace', () => {
73+
expect(() => resolveImages({ ...defaults, agentImage: 'ubuntu 22.04' })).toThrow(
74+
'must not contain whitespace',
75+
);
76+
});
6577
});
6678

6779
describe('predownloadCommand', () => {
@@ -104,49 +116,41 @@ describe('predownload', () => {
104116
);
105117
});
106118

107-
it('should exit with code 1 when a pull fails', async () => {
108-
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
109-
throw new Error('process.exit called');
110-
});
119+
it('should throw with exitCode 1 when a pull fails', async () => {
111120
execa
112121
.mockResolvedValueOnce({ stdout: '', stderr: '' })
113122
.mockRejectedValueOnce(new Error('pull failed'));
114123

115-
await expect(predownloadCommand(defaults)).rejects.toThrow(
116-
'process.exit called',
117-
);
118-
119-
expect(mockExit).toHaveBeenCalledWith(1);
120-
mockExit.mockRestore();
124+
try {
125+
await predownloadCommand(defaults);
126+
fail('Expected predownloadCommand to throw');
127+
} catch (error) {
128+
expect((error as Error).message).toBe('1 of 2 image(s) failed to pull');
129+
expect((error as Error & { exitCode?: number }).exitCode).toBe(1);
130+
}
121131
});
122132

123133
it('should continue pulling remaining images after a failure', async () => {
124-
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
125-
throw new Error('process.exit called');
126-
});
127134
execa.mockRejectedValueOnce(new Error('pull failed')).mockResolvedValueOnce({ stdout: '', stderr: '' });
128135

129136
await expect(predownloadCommand(defaults)).rejects.toThrow(
130-
'process.exit called',
137+
'1 of 2 image(s) failed to pull',
131138
);
132139

133140
// Both images should have been attempted
134141
expect(execa).toHaveBeenCalledTimes(2);
135-
mockExit.mockRestore();
136142
});
137143

138144
it('should handle non-Error rejection', async () => {
139-
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
140-
throw new Error('process.exit called');
141-
});
142145
execa.mockRejectedValueOnce('string error').mockResolvedValueOnce({ stdout: '', stderr: '' });
143146

144-
await expect(predownloadCommand(defaults)).rejects.toThrow(
145-
'process.exit called',
146-
);
147-
148-
expect(mockExit).toHaveBeenCalledWith(1);
149-
mockExit.mockRestore();
147+
try {
148+
await predownloadCommand(defaults);
149+
fail('Expected predownloadCommand to throw');
150+
} catch (error) {
151+
expect((error as Error).message).toBe('1 of 2 image(s) failed to pull');
152+
expect((error as Error & { exitCode?: number }).exitCode).toBe(1);
153+
}
150154
});
151155
});
152156
});

src/commands/predownload.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ export interface PredownloadOptions {
88
enableApiProxy: boolean;
99
}
1010

11+
/**
12+
* Validates a custom Docker image reference.
13+
* Rejects values that could be interpreted as Docker CLI flags or contain whitespace.
14+
*/
15+
function validateImageReference(image: string): void {
16+
if (image.startsWith('-')) {
17+
throw new Error(`Invalid image reference "${image}": must not start with "-"`);
18+
}
19+
if (/\s/.test(image)) {
20+
throw new Error(`Invalid image reference "${image}": must not contain whitespace`);
21+
}
22+
}
23+
1124
/**
1225
* Resolves the list of image references to pull based on the given options.
1326
*/
@@ -24,7 +37,8 @@ export function resolveImages(options: PredownloadOptions): string[] {
2437
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
2538
images.push(`${imageRegistry}/${imageName}:${imageTag}`);
2639
} else {
27-
// Custom image - pull as-is
40+
// Custom image - validate and pull as-is
41+
validateImageReference(agentImage);
2842
images.push(agentImage);
2943
}
3044

@@ -57,8 +71,11 @@ export async function predownloadCommand(options: PredownloadOptions): Promise<v
5771
}
5872

5973
if (failed > 0) {
60-
logger.error(`${failed} of ${images.length} image(s) failed to pull`);
61-
process.exit(1);
74+
const message = `${failed} of ${images.length} image(s) failed to pull`;
75+
logger.error(message);
76+
const error: Error & { exitCode?: number } = new Error(message);
77+
error.exitCode = 1;
78+
throw error;
6279
}
6380

6481
logger.info(`All ${images.length} image(s) pre-downloaded successfully`);

0 commit comments

Comments
 (0)