diff --git a/.github/workflows/smoke-chroot.lock.yml b/.github/workflows/smoke-chroot.lock.yml index 3bdac71e0..95bcd95ff 100644 --- a/.github/workflows/smoke-chroot.lock.yml +++ b/.github/workflows/smoke-chroot.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c076e29a618097d8e5e4c3c5760d406554243ed0f3e15cbec412f4775852f52b","body_hash":"72a920dfe6fe26462bdbb902164f4fb47a1abb12e9d97bec9c054dde82c34a8c","compiler_version":"v0.79.6","agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4deb227db0dc9218844b2ef751da1791a35b9b42cc0c224f9164cd86cf516e8b","body_hash":"72a920dfe6fe26462bdbb902164f4fb47a1abb12e9d97bec9c054dde82c34a8c","compiler_version":"v0.79.6","agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"5c2fe865bb4dc46e1450f6ee0d0541d759aea73a","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.2","digest":"sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.2@sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:latest"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # ___ _ _ # / _ \ | | (_) diff --git a/scripts/ci/security-guard-workflow.test.ts b/scripts/ci/security-guard-workflow.test.ts index f7d47d1ad..f7ffd7e4f 100644 --- a/scripts/ci/security-guard-workflow.test.ts +++ b/scripts/ci/security-guard-workflow.test.ts @@ -38,7 +38,7 @@ describe('security guard workflow optimization config', () => { expect(lock).toContain('--max-turns 6'); expect(lock).toContain('ANTHROPIC_MODEL: claude-sonnet-4-5'); expect(lock).toContain('GH_AW_MAX_TURNS: 6'); - expect(lock).toContain('github/gh-aw-actions/setup@d059700c6a8ec3b5fd798b9ea60f5d048447b918 # v0.79.4'); + expect(lock).toContain('github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6'); expect(lock).not.toContain('github/gh-aw-actions/setup@v0.79.2'); expect(lock).toContain('ghcr.io/github/github-mcp-server:v1.1.2'); }); diff --git a/scripts/ci/test-coverage-improver-workflow.test.ts b/scripts/ci/test-coverage-improver-workflow.test.ts index ee6cbbf7f..06831382b 100644 --- a/scripts/ci/test-coverage-improver-workflow.test.ts +++ b/scripts/ci/test-coverage-improver-workflow.test.ts @@ -75,7 +75,7 @@ describe('test coverage improver workflow token optimization config', () => { expect(lock).not.toContain("shell(cat:src/*.test.ts)"); expect(lock).not.toContain("shell(npm run lint)"); expect(lock).not.toContain("shell(npm run test)"); - expect(lock).toContain('github/gh-aw-actions/setup@d059700c6a8ec3b5fd798b9ea60f5d048447b918 # v0.79.4'); + expect(lock).toContain('github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6'); expect(lock).not.toContain('github/gh-aw-actions/setup@v0.79.2'); expect(lock).toContain('ghcr.io/github/github-mcp-server:v1.1.2'); diff --git a/src/container-lifecycle-retry-timeout.test.ts b/src/container-lifecycle-retry-timeout.test.ts new file mode 100644 index 000000000..bb48fe500 --- /dev/null +++ b/src/container-lifecycle-retry-timeout.test.ts @@ -0,0 +1,326 @@ +/** + * Targeted tests for uncovered branches in container-lifecycle.ts: + * - startContainers retry failure when api-proxy fails on both attempts + * - startContainers cli-proxy first-attempt failure (no retry) + * - startContainers cli-proxy failure on the retry attempt + * - startContainers graceful handling of runComposeDown failure before retry + * - runAgentCommand agentTimeoutMinutes path (exitCode 124, docker stop called) + * - runAgentCommand isAgentExternallyKilled short-circuit path + * - fastKillAgentContainer silent error handling + * - fastKillAgentContainer default and custom stop-timeout + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory()); +jest.mock('./container-startup-diagnostics'); +jest.mock('./squid-log-reader', () => ({ + checkSquidLogs: jest.fn().mockResolvedValue({ hasDenials: false, blockedTargets: [] }), +})); + +import { startContainers, runAgentCommand, fastKillAgentContainer } from './container-lifecycle'; +import { containerLifecycleTestHelpers } from './container-lifecycle.test-utils'; +import { markAgentExternallyKilled } from './container-lifecycle-state'; +import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; +import { useTempDir } from './test-helpers/docker-test-fixtures.test-utils'; +import { + didContainerFailStartup, + handleHealthcheckError, + logContainerLogsToStderr, +} from './container-startup-diagnostics'; + +const mockDidContainerFailStartup = jest.mocked(didContainerFailStartup); +const mockHandleHealthcheckError = jest.mocked(handleHealthcheckError); +const mockLogContainerLogsToStderr = jest.mocked(logContainerLogsToStderr); + +function ok(stdout = '', stderr = '', exitCode = 0): { stdout: string; stderr: string; exitCode: number } { + return { stdout, stderr, exitCode }; +} + +describe('container-lifecycle retry and timeout branches', () => { + const { getDir } = useTempDir(); + + beforeEach(() => { + mockExecaFn.mockReset(); + containerLifecycleTestHelpers.resetAgentExternallyKilled(); + + mockDidContainerFailStartup.mockReset(); + mockHandleHealthcheckError.mockReset(); + mockLogContainerLogsToStderr.mockReset(); + + // Default mock behaviours — individual tests override with mockResolvedValueOnce as needed + mockDidContainerFailStartup.mockResolvedValue(false); + mockHandleHealthcheckError.mockRejectedValue(new Error('healthcheck failed')); + mockLogContainerLogsToStderr.mockResolvedValue(undefined); + }); + + // ─── startContainers retry failure: api-proxy fails both attempts ──────────── + + describe('startContainers – api-proxy fails on first attempt and on retry', () => { + it('throws with an api-proxy-specific message when the retry also fails', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker rm -f (cleanup) + .mockRejectedValueOnce(new Error('compose up first attempt failed')) // first compose up + .mockResolvedValueOnce(ok() as any) // docker compose down (runComposeDown before retry) + .mockRejectedValueOnce(new Error('compose up retry failed')); // retry compose up + + // First attempt: api-proxy flagged as failed + mockDidContainerFailStartup + .mockResolvedValueOnce(true) // first attempt: awf-api-proxy → true + // Retry attempt checks: + .mockResolvedValueOnce(true); // retry: awf-api-proxy → true (fails again) + + await expect(startContainers(getDir(), ['github.com'])).rejects.toThrow( + 'awf-api-proxy failed to start on both attempts' + ); + + // logContainerLogsToStderr called twice: once before retry, once on retry failure + expect(mockLogContainerLogsToStderr).toHaveBeenCalledTimes(2); + }); + }); + + // ─── startContainers: squid fails on first attempt, retry fails ───────────── + + describe('startContainers – squid fails on first attempt, retry fails with squid error', () => { + it('logs squid container and falls through to handleHealthcheckError on retry failure', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker rm -f + .mockRejectedValueOnce(new Error('compose up first attempt failed')) + .mockResolvedValueOnce(ok() as any) // docker compose down + .mockRejectedValueOnce(new Error('compose up retry failed')); + + // First attempt: squid flagged as failed (api-proxy check returns false) + mockDidContainerFailStartup + .mockResolvedValueOnce(false) // first: api-proxy → false + .mockResolvedValueOnce(true) // first: squid → true + // Retry attempt checks: + .mockResolvedValueOnce(false) // retry: api-proxy → false + .mockResolvedValueOnce(true) // retry: squid → true (logs dumped, but no throw) + .mockResolvedValueOnce(false); // retry: cli-proxy → false (falls through) + + await expect(startContainers(getDir(), ['github.com'])).rejects.toThrow('healthcheck failed'); + + // handleHealthcheckError was invoked because squid failure doesn't throw directly on retry + expect(mockHandleHealthcheckError).toHaveBeenCalledTimes(1); + }); + }); + + // ─── startContainers: cli-proxy fails on first attempt (no retry) ─────────── + + describe('startContainers – cli-proxy fails on first attempt', () => { + it('throws with a cli-proxy-specific message without retrying', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker rm -f + .mockRejectedValueOnce(new Error('compose up failed')); + + // All three container checks: only cli-proxy returns true + mockDidContainerFailStartup + .mockResolvedValueOnce(false) // api-proxy → false + .mockResolvedValueOnce(false) // squid → false + .mockResolvedValueOnce(true); // cli-proxy → true + + await expect(startContainers(getDir(), ['github.com'])).rejects.toThrow( + 'awf-cli-proxy could not connect to the external DIFC proxy' + ); + + // No retry: compose up called only once + const upCalls = mockExecaFn.mock.calls.filter( + (call: unknown[]) => call[0] === 'docker' && Array.isArray(call[1]) && (call[1] as string[]).includes('up') + ); + expect(upCalls).toHaveLength(1); + }); + + it('includes the "agent was never invoked" note in the error', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) + .mockRejectedValueOnce(new Error('compose up failed')); + + mockDidContainerFailStartup + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + await expect(startContainers(getDir(), ['github.com'])).rejects.toThrow( + 'The agent was never invoked' + ); + }); + }); + + // ─── startContainers: cli-proxy fails on the retry attempt ────────────────── + + describe('startContainers – cli-proxy fails during the retry attempt', () => { + it('throws with a cli-proxy-specific message when cli-proxy fails on retry', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker rm -f + .mockRejectedValueOnce(new Error('compose up first failed')) + .mockResolvedValueOnce(ok() as any) // docker compose down + .mockRejectedValueOnce(new Error('compose up retry failed')); + + mockDidContainerFailStartup + // First attempt: api-proxy triggers the retry + .mockResolvedValueOnce(true) // first: api-proxy → true (triggers retry) + // Retry attempt checks: + .mockResolvedValueOnce(false) // retry: api-proxy → false + .mockResolvedValueOnce(false) // retry: squid → false + .mockResolvedValueOnce(true); // retry: cli-proxy → true + + await expect(startContainers(getDir(), ['github.com'])).rejects.toThrow( + 'awf-cli-proxy could not connect to the external DIFC proxy' + ); + }); + }); + + // ─── startContainers: cleanup before retry can fail gracefully ─────────────── + + describe('startContainers – runComposeDown failure before retry', () => { + it('proceeds with retry even when the compose-down teardown throws', async () => { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker rm -f + .mockRejectedValueOnce(new Error('compose up first failed')) + .mockRejectedValueOnce(new Error('compose down also failed')) // runComposeDown throws + .mockResolvedValueOnce(ok() as any); // retry compose up succeeds + + mockDidContainerFailStartup.mockResolvedValueOnce(true); // first: api-proxy → true (retry path) + + // The compose-down failure should be silently ignored; retry succeeds + await expect(startContainers(getDir(), ['github.com'])).resolves.toBeUndefined(); + }); + }); + + // ─── runAgentCommand: timeout path ────────────────────────────────────────── + + describe('runAgentCommand – agentTimeoutMinutes', () => { + it('returns exit code 124 when the container exceeds the timeout', async () => { + jest.useFakeTimers(); + try { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker logs -f (logsProcess) + .mockReturnValueOnce(new Promise(() => {})) // docker wait (never resolves) + .mockResolvedValueOnce(ok() as any); // docker stop -t 10 + + const resultPromise = runAgentCommand(getDir(), ['github.com'], undefined, 1); + + // Fire the 1-minute timeout (1 * 60 * 1000 ms) + await jest.advanceTimersByTimeAsync(60_001); + // Fire the 200 ms Squid-log flush delay + await jest.advanceTimersByTimeAsync(300); + + const result = await resultPromise; + + expect(result.exitCode).toBe(124); + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['stop', '-t', '10', 'awf-agent'], + expect.objectContaining({ reject: false }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('uses the docker-wait exit code when the container exits before the timeout', async () => { + jest.useFakeTimers(); + try { + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker logs -f + .mockResolvedValueOnce(ok('42') as any); // docker wait → exit code 42 + + const resultPromise = runAgentCommand(getDir(), ['github.com'], undefined, 5); + + // The docker-wait mock resolves immediately (before the 5-minute timeout) + // but we still need to advance past the 200 ms squid-log flush delay + await jest.advanceTimersByTimeAsync(300); + + const result = await resultPromise; + + expect(result.exitCode).toBe(42); + // docker stop should NOT have been called + const stopCalls = mockExecaFn.mock.calls.filter( + (call: unknown[]) => + call[0] === 'docker' && Array.isArray(call[1]) && (call[1] as string[])[0] === 'stop' + ); + expect(stopCalls).toHaveLength(0); + } finally { + jest.useRealTimers(); + } + }); + }); + + // ─── runAgentCommand: externally-killed path ───────────────────────────────── + + describe('runAgentCommand – isAgentExternallyKilled', () => { + it('skips squid-log analysis and returns the docker-wait exit code when externally killed', async () => { + markAgentExternallyKilled(); + + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker logs -f + .mockResolvedValueOnce(ok('143') as any); // docker wait + + const result = await runAgentCommand(getDir(), ['github.com']); + + expect(result.exitCode).toBe(143); + expect(result.blockedDomains).toEqual([]); + }); + + it('falls back to exit code 143 when docker wait reports 0 and agent was externally killed', async () => { + markAgentExternallyKilled(); + + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker logs -f + .mockResolvedValueOnce(ok('0') as any); // docker wait returns 0 + + const result = await runAgentCommand(getDir(), ['github.com']); + + // exitCode 0 is falsy → 0 || 143 === 143 + expect(result.exitCode).toBe(143); + }); + }); + + // ─── fastKillAgentContainer ────────────────────────────────────────────────── + + describe('fastKillAgentContainer', () => { + it('calls docker stop with the default 3-second timeout', async () => { + mockExecaFn.mockResolvedValueOnce(ok() as any); + + await fastKillAgentContainer(); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['stop', '-t', '3', 'awf-agent'], + expect.objectContaining({ reject: false }), + ); + }); + + it('calls docker stop with the provided timeout', async () => { + mockExecaFn.mockResolvedValueOnce(ok() as any); + + await fastKillAgentContainer(7); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['stop', '-t', '7', 'awf-agent'], + expect.any(Object), + ); + }); + + it('silently swallows errors from docker stop', async () => { + mockExecaFn.mockRejectedValueOnce(new Error('docker daemon not responding')); + + // Must not throw + await expect(fastKillAgentContainer()).resolves.toBeUndefined(); + }); + + it('marks the agent as externally killed even when docker stop fails', async () => { + mockExecaFn.mockRejectedValueOnce(new Error('docker CLI unavailable')); + + await fastKillAgentContainer(); + + // Verify the flag was set: a subsequent runAgentCommand call should skip squid analysis + mockExecaFn + .mockResolvedValueOnce(ok() as any) // docker logs -f + .mockResolvedValueOnce(ok('137') as any); // docker wait + + const result = await runAgentCommand(getDir(), ['github.com']); + expect(result.blockedDomains).toEqual([]); + }); + }); +});