diff --git a/src/config-writer.test.ts b/src/config-writer.test.ts index 9372bd3d..7e08552f 100644 --- a/src/config-writer.test.ts +++ b/src/config-writer.test.ts @@ -189,5 +189,309 @@ describe('writeConfigs', () => { const mcpLogsDirMode = fs.statSync(mcpLogsDir).mode & 0o777; expect(mcpLogsDirMode).toBe(0o777); }); + + it('throws when workDir path exists but is not a directory', async () => { + const filePath = path.join(tempDir, 'not-a-directory'); + fs.writeFileSync(filePath, 'content'); + + await expect( + writeConfigs({ + workDir: filePath, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }) + ).rejects.toThrow(/EEXIST|ENOTDIR/); + }); + + it('creates chroot home directory when it does not exist', async () => { + const emptyHomeDir = `${tempDir}-chroot-home`; + expect(fs.existsSync(emptyHomeDir)).toBe(false); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + expect(fs.existsSync(emptyHomeDir)).toBe(true); + expect(fs.statSync(emptyHomeDir).isDirectory()).toBe(true); + }); + + it('uses existing chroot home directory if already present', async () => { + const emptyHomeDir = `${tempDir}-chroot-home`; + fs.mkdirSync(emptyHomeDir, { recursive: true }); + const statBefore = fs.statSync(emptyHomeDir); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + const statAfter = fs.statSync(emptyHomeDir); + expect(statAfter.ino).toBe(statBefore.ino); // Same directory + }); + + it('creates missing home subdirectories with correct ownership', async () => { + const homeDir = tempDir; + (getRealUserHome as jest.Mock).mockReturnValue(homeDir); + + // Delete .copilot if it exists + const copilotDir = path.join(homeDir, '.copilot'); + if (fs.existsSync(copilotDir)) { + fs.rmSync(copilotDir, { recursive: true, force: true }); + } + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + expect(fs.existsSync(copilotDir)).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(copilotDir, 1000, 1000); + }); + + it('creates .gemini directory when geminiApiKey is provided', async () => { + const homeDir = tempDir; + (getRealUserHome as jest.Mock).mockReturnValue(homeDir); + + const geminiDir = path.join(homeDir, '.gemini'); + if (fs.existsSync(geminiDir)) { + fs.rmSync(geminiDir, { recursive: true, force: true }); + } + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + geminiApiKey: 'test-key', + }); + + expect(fs.existsSync(geminiDir)).toBe(true); + }); + + it('does not create .gemini directory when geminiApiKey is not provided', async () => { + const homeDir = tempDir; + (getRealUserHome as jest.Mock).mockReturnValue(homeDir); + + const geminiDir = path.join(homeDir, '.gemini'); + if (fs.existsSync(geminiDir)) { + fs.rmSync(geminiDir, { recursive: true, force: true }); + } + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + expect(fs.existsSync(geminiDir)).toBe(false); + }); + + it('creates audit directory when it does not exist', async () => { + const auditDir = path.join(tempDir, 'custom-audit'); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + auditDir, + }); + + expect(fs.existsSync(auditDir)).toBe(true); + expect(fs.existsSync(path.join(auditDir, 'squid.conf'))).toBe(true); + expect(fs.existsSync(path.join(auditDir, 'docker-compose.redacted.yml'))).toBe(true); + expect(fs.existsSync(path.join(auditDir, 'policy-manifest.json'))).toBe(true); + }); + }); + + describe('seccomp profile', () => { + it('throws error when seccomp profile is not found', async () => { + const originalExistsSync = fs.existsSync; + const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation((filePath: fs.PathLike) => { + const normalizedPath = + typeof filePath === 'string' ? filePath : filePath.toString(); + + if ( + normalizedPath === 'seccomp-profile.json' || + normalizedPath.endsWith(`${path.sep}seccomp-profile.json`) + ) { + return false; + } + + return originalExistsSync(filePath); + }); + + try { + await expect( + writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }) + ).rejects.toThrow(/Seccomp profile not found/); + } finally { + existsSyncSpy.mockRestore(); + } + }); + }); + + describe('URL patterns and API proxy', () => { + beforeEach(() => { + const { parseUrlPatterns } = jest.requireMock('./ssl-bump'); + parseUrlPatterns.mockReturnValue(['https://example\\.com/.*']); + }); + + it('parses URL patterns when allowedUrls is provided', async () => { + const { parseUrlPatterns } = jest.requireMock('./ssl-bump'); + const { generateSquidConfig } = jest.requireMock('./squid-config'); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: ['example.com'], + allowedUrls: ['https://example.com/api/*'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + expect(parseUrlPatterns).toHaveBeenCalledWith(['https://example.com/api/*']); + expect(generateSquidConfig).toHaveBeenCalledWith( + expect.objectContaining({ + urlPatterns: ['https://example\\.com/.*'], + }) + ); + }); + + it('does not parse URL patterns when allowedUrls is empty', async () => { + const { parseUrlPatterns } = jest.requireMock('./ssl-bump'); + const { generateSquidConfig } = jest.requireMock('./squid-config'); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: ['example.com'], + allowedUrls: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }); + + expect(parseUrlPatterns).not.toHaveBeenCalled(); + expect(generateSquidConfig).toHaveBeenCalledWith( + expect.objectContaining({ + urlPatterns: undefined, + }) + ); + }); + + it('includes API proxy configuration when enableApiProxy is true', async () => { + const { generateSquidConfig } = jest.requireMock('./squid-config'); + const { generatePolicyManifest } = jest.requireMock('./squid-config'); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: ['example.com'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + enableApiProxy: true, + }); + + expect(generateSquidConfig).toHaveBeenCalledWith( + expect.objectContaining({ + apiProxyIp: '172.30.0.30', + apiProxyPorts: expect.arrayContaining([10000, 10001, 10002, 10003]), + }) + ); + expect(generatePolicyManifest).toHaveBeenCalledWith( + expect.objectContaining({ + apiProxyIp: '172.30.0.30', + }) + ); + }); + + it('does not include API proxy configuration when enableApiProxy is false', async () => { + const { generateSquidConfig } = jest.requireMock('./squid-config'); + + await writeConfigs({ + workDir: tempDir, + sslBump: false, + allowedDomains: ['example.com'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + enableApiProxy: false, + }); + + expect(generateSquidConfig).toHaveBeenCalledWith( + expect.not.objectContaining({ + apiProxyIp: expect.anything(), + }) + ); + }); }); });