From f160980bcef8cb3c65b43761cb3db213a36bb191 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:51:16 +0000 Subject: [PATCH 1/3] Initial plan From 905c550e3b61cd9d28183bc41fbc6d62ef75c762 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:10:16 +0000 Subject: [PATCH 2/3] refactor(tests): extract useAgentVolumesTestConfig helper Adds `useAgentVolumesTestConfig()` to `service-test-setup.test-utils.ts` and updates all four `agent-volumes-*.test.ts` files to use it, removing the ~18 duplicated lines of useTempWorkDir boilerplate from each file. Closes #3320 --- src/services/agent-volumes-logs.test.ts | 24 ++-- src/services/agent-volumes-mounts.test.ts | 112 ++++++++---------- src/services/agent-volumes-security.test.ts | 42 +++---- src/services/agent-volumes-workspace.test.ts | 28 ++--- src/services/service-test-setup.test-utils.ts | 38 +++++- 5 files changed, 121 insertions(+), 123 deletions(-) diff --git a/src/services/agent-volumes-logs.test.ts b/src/services/agent-volumes-logs.test.ts index 877a83ef2..a9dfa71b3 100644 --- a/src/services/agent-volumes-logs.test.ts +++ b/src/services/agent-volumes-logs.test.ts @@ -1,25 +1,17 @@ -import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { generateDockerCompose, WrapperConfig, mockNetworkConfig, useAgentVolumesTestConfig } from './service-test-setup.test-utils'; // Create mock functions (must remain per-file — jest.mock() is hoisted before imports) // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); -let mockConfig: WrapperConfig; +const { getConfig } = useAgentVolumesTestConfig(); describe('agent service', () => { - useTempWorkDir( - baseConfig, - (config) => { - mockConfig = config; - }, - () => mockConfig - ); - describe('proxyLogsDir option', () => { it('should use proxyLogsDir when specified', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), proxyLogsDir: '/custom/proxy/logs', }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -29,15 +21,15 @@ describe('agent service', () => { }); it('should use workDir/squid-logs when proxyLogsDir is not specified', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const squid = result.services['squid-proxy']; - expect(squid.volumes).toContain(`${mockConfig.workDir}/squid-logs:/var/log/squid:rw`); + expect(squid.volumes).toContain(`${getConfig().workDir}/squid-logs:/var/log/squid:rw`); }); it('should use api-proxy-logs subdirectory inside proxyLogsDir when specified', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), proxyLogsDir: '/custom/proxy/logs', enableApiProxy: true, openaiApiKey: 'sk-test-key', @@ -53,7 +45,7 @@ describe('agent service', () => { it('should use workDir/api-proxy-logs when proxyLogsDir is not specified', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), enableApiProxy: true, openaiApiKey: 'sk-test-key', }; @@ -63,7 +55,7 @@ describe('agent service', () => { }); const apiProxy = result.services['api-proxy']; - expect(apiProxy.volumes).toContain(`${mockConfig.workDir}/api-proxy-logs:/var/log/api-proxy:rw`); + expect(apiProxy.volumes).toContain(`${getConfig().workDir}/api-proxy-logs:/var/log/api-proxy:rw`); }); }); }); diff --git a/src/services/agent-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index 08e956c8b..50d8f6cba 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { generateDockerCompose, mockNetworkConfig, useAgentVolumesTestConfig } from './service-test-setup.test-utils'; import { logger } from '../logger'; import * as fs from 'fs'; import * as path from 'path'; @@ -13,7 +13,7 @@ import { mockExecaSync } from '../test-helpers/mock-execa.test-utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); -let mockConfig: WrapperConfig; +const { getConfig } = useAgentVolumesTestConfig(); function withEnv(envPatch: Record, fn: () => void): void { const saved: Record = {}; @@ -41,16 +41,8 @@ function withEnv(envPatch: Record, fn: () => void): } describe('agent service', () => { - useTempWorkDir( - baseConfig, - (config) => { - mockConfig = config; - }, - () => mockConfig - ); - - it('should mount required volumes in agent container (default behavior)', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + it('should mount required volumes in agent container (default behavior)', () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -66,7 +58,7 @@ describe('agent service', () => { it('should use custom volume mounts when specified', () => { const configWithMounts = { - ...mockConfig, + ...getConfig(), volumeMounts: ['/workspace:/workspace:ro', '/data:/data:rw'] }; const result = generateDockerCompose(configWithMounts, mockNetworkConfig); @@ -87,7 +79,7 @@ describe('agent service', () => { it('should apply dockerHostPathPrefix to bind-mount source paths', () => { const configWithPrefix = { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: '/daemon-root', volumeMounts: ['/workspace:/workspace:ro'], }; @@ -100,7 +92,7 @@ describe('agent service', () => { expect(volumes).toContain('/daemon-root/workspace:/host/workspace:ro'); expect(volumes).toContain('/dev/null:/host/var/run/docker.sock:ro'); expect(volumes).toContain('/dev/null:/host/run/docker.sock:ro'); - expect(volumes.some((v: string) => v.startsWith(`/daemon-root${mockConfig.workDir}/chroot-`) && v.endsWith(':/host/etc/hosts:ro'))).toBe(true); + expect(volumes.some((v: string) => v.startsWith(`/daemon-root${getConfig().workDir}/chroot-`) && v.endsWith(':/host/etc/hosts:ro'))).toBe(true); // Kernel virtual filesystems should NOT be prefixed — they are daemon-local expect(volumes).toContain('/dev:/host/dev:ro'); @@ -111,7 +103,7 @@ describe('agent service', () => { it('should normalize trailing slash in dockerHostPathPrefix', () => { const configWithPrefix = { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: '/daemon-root/', }; const result = generateDockerCompose(configWithPrefix, mockNetworkConfig); @@ -123,7 +115,7 @@ describe('agent service', () => { it('should auto-stage the ARC/DinD manual bootstrap files under a shared /tmp docker-host-path-prefix', () => { const originalPath = process.env.PATH; const sharedTmpPrefix = fs.mkdtempSync(path.join('/tmp', 'gh-aw-')); - const fakeBinDir = path.join(mockConfig.workDir, 'fake-bin'); + const fakeBinDir = path.join(getConfig().workDir, 'fake-bin'); fs.mkdirSync(fakeBinDir, { recursive: true }); const fakeCopilotPath = path.join(fakeBinDir, 'copilot'); fs.writeFileSync(fakeCopilotPath, '#!/bin/sh\necho copilot\n', { mode: 0o755 }); @@ -137,7 +129,7 @@ describe('agent service', () => { try { const configWithTmpPrefix = { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: sharedTmpPrefix, agentCommand: 'copilot --version', enableHostAccess: true, @@ -190,8 +182,8 @@ describe('agent service', () => { it('should skip non-executable PATH candidates when staging the runner binary', () => { const originalPath = process.env.PATH; - const nonExecutableDir = path.join(mockConfig.workDir, 'fake-bin-nonexec'); - const executableDir = path.join(mockConfig.workDir, 'fake-bin-exec'); + const nonExecutableDir = path.join(getConfig().workDir, 'fake-bin-nonexec'); + const executableDir = path.join(getConfig().workDir, 'fake-bin-exec'); fs.mkdirSync(nonExecutableDir, { recursive: true }); fs.mkdirSync(executableDir, { recursive: true }); fs.writeFileSync(path.join(nonExecutableDir, 'copilot'), '#!/bin/sh\necho wrong\n', { mode: 0o644 }); @@ -201,7 +193,7 @@ describe('agent service', () => { try { generateDockerCompose( { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw', agentCommand: 'copilot --version', }, @@ -221,8 +213,8 @@ describe('agent service', () => { it('should prefer an explicit command path when staging the runner binary', () => { const originalPath = process.env.PATH; - const fakeBinDir = path.join(mockConfig.workDir, 'fake-bin-path'); - const explicitBinDir = path.join(mockConfig.workDir, 'explicit-bin'); + const fakeBinDir = path.join(getConfig().workDir, 'fake-bin-path'); + const explicitBinDir = path.join(getConfig().workDir, 'explicit-bin'); fs.mkdirSync(fakeBinDir, { recursive: true }); fs.mkdirSync(explicitBinDir, { recursive: true }); fs.writeFileSync(path.join(fakeBinDir, 'copilot'), '#!/bin/sh\necho path\n', { mode: 0o755 }); @@ -233,7 +225,7 @@ describe('agent service', () => { try { generateDockerCompose( { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw', agentCommand: `${explicitBinaryPath} --version`, }, @@ -269,7 +261,7 @@ describe('agent service', () => { const result = generateDockerCompose( { - ...mockConfig, + ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw', }, mockNetworkConfig, @@ -284,7 +276,7 @@ describe('agent service', () => { it('should mount api-proxy health-check script when api-proxy is enabled', () => { const configWithApiProxy = { - ...mockConfig, + ...getConfig(), enableApiProxy: true, }; const result = generateDockerCompose(configWithApiProxy, mockNetworkConfig); @@ -295,7 +287,7 @@ describe('agent service', () => { it('should apply dockerHostPathPrefix to api-proxy health-check script mount', () => { const configWithApiProxyAndPrefix = { - ...mockConfig, + ...getConfig(), enableApiProxy: true, dockerHostPathPrefix: '/daemon-root', }; @@ -307,7 +299,7 @@ describe('agent service', () => { it('should handle malformed volume mount without colon as fallback', () => { const configWithBadMount = { - ...mockConfig, + ...getConfig(), volumeMounts: ['no-colon-here'] }; const result = generateDockerCompose(configWithBadMount, mockNetworkConfig); @@ -318,11 +310,11 @@ describe('agent service', () => { }); it('should reject staged target paths that escape the docker-host staging root', () => { - const sourceFile = path.join(mockConfig.workDir, 'stage-source.txt'); + const sourceFile = path.join(getConfig().workDir, 'stage-source.txt'); fs.writeFileSync(sourceFile, 'stage me'); const stagedPath = stageHostFile( - { ...mockConfig, dockerHostPathPrefix: '/tmp/gh-aw' }, + { ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw' }, sourceFile, '../escaped.txt', ); @@ -332,7 +324,7 @@ describe('agent service', () => { }); it('should use selective mounts by default', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -372,7 +364,7 @@ describe('agent service', () => { }); it('should expose Docker socket when enableDind is true', () => { - const dindConfig = { ...mockConfig, enableDind: true }; + const dindConfig = { ...getConfig(), enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -387,7 +379,7 @@ describe('agent service', () => { it('should expose the Unix DOCKER_HOST socket path when enableDind is true', () => { withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { - const dindConfig = { ...mockConfig, enableDind: true }; + const dindConfig = { ...getConfig(), enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; @@ -400,7 +392,7 @@ describe('agent service', () => { it('should prefer awfDockerHost over DOCKER_HOST when enableDind is true', () => { withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { const dindConfig = { - ...mockConfig, + ...getConfig(), enableDind: true, awfDockerHost: 'unix:///run/user/1000/docker.sock', }; @@ -417,7 +409,7 @@ describe('agent service', () => { it('should set agent DOCKER_HOST from awfDockerHost when enableDind is true and host DOCKER_HOST is unset', () => { withEnv({ DOCKER_HOST: undefined }, () => { const dindConfig = { - ...mockConfig, + ...getConfig(), enableDind: true, awfDockerHost: 'unix:///run/user/1000/docker.sock', }; @@ -437,7 +429,7 @@ describe('agent service', () => { try { withEnv({ DOCKER_HOST: 'unix://relative/path' }, () => { - const dindConfig = { ...mockConfig, enableDind: true }; + const dindConfig = { ...getConfig(), enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; @@ -452,7 +444,7 @@ describe('agent service', () => { }); it('should mount Rust toolchain, Node/npm caches, and CLI state directories', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -474,12 +466,12 @@ describe('agent service', () => { expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`); } // session-state and logs are always overlaid from AWF workDir - expect(volumes).toContain(`${mockConfig.workDir}/agent-session-state:/host${homeDir}/.copilot/session-state:rw`); - expect(volumes).toContain(`${mockConfig.workDir}/agent-logs:/host${homeDir}/.copilot/logs:rw`); + expect(volumes).toContain(`${getConfig().workDir}/agent-session-state:/host${homeDir}/.copilot/session-state:rw`); + expect(volumes).toContain(`${getConfig().workDir}/agent-logs:/host${homeDir}/.copilot/logs:rw`); }); it('should mount ~/.gemini when geminiApiKey is configured', () => { - const configWithGemini = { ...mockConfig, geminiApiKey: 'AIza-test-gemini-key' }; + const configWithGemini = { ...getConfig(), geminiApiKey: 'AIza-test-gemini-key' }; const result = generateDockerCompose(configWithGemini, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; @@ -495,7 +487,7 @@ describe('agent service', () => { const toolcacheDir = path.join(fakeHome, 'work', '_tool'); fs.mkdirSync(toolcacheDir, { recursive: true }); - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); @@ -516,7 +508,7 @@ describe('agent service', () => { const toolcacheDir = path.join(workDir, '_tool'); fs.symlinkSync(symlinkTarget, toolcacheDir); - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); @@ -535,7 +527,7 @@ describe('agent service', () => { const copilotDir = path.join(fakeHome, '.copilot'); expect(fs.existsSync(copilotDir)).toBe(false); - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; // Directory should NOT be auto-created (changed in #2114) @@ -554,7 +546,7 @@ describe('agent service', () => { }); it('should use sessionStateDir when specified for chroot mounts', () => { - const configWithSessionDir = { ...mockConfig, sessionStateDir: '/custom/session-state' }; + const configWithSessionDir = { ...getConfig(), sessionStateDir: '/custom/session-state' }; const result = generateDockerCompose(configWithSessionDir, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; const homeDir = process.env.HOME || '/root'; @@ -563,7 +555,7 @@ describe('agent service', () => { }); it('should mount /tmp under /host for chroot temp scripts', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -572,7 +564,7 @@ describe('agent service', () => { }); it('should mount /etc/passwd and /etc/group for user lookup in chroot mode', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -584,7 +576,7 @@ describe('agent service', () => { it('should mount read-only chroot-hosts when enableHostAccess is true', () => { const config = { - ...mockConfig, + ...getConfig(), enableHostAccess: true }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -599,15 +591,15 @@ describe('agent service', () => { it('should inject host.docker.internal into chroot-hosts file', () => { const config = { - ...mockConfig, + ...getConfig(), enableHostAccess: true }; generateDockerCompose(config, mockNetworkConfig); // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(mockConfig.workDir).find(d => d.startsWith('chroot-')); + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${mockConfig.workDir}/${chrootDir}/hosts`; + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; expect(fs.existsSync(chrootHostsPath)).toBe(true); const content = fs.readFileSync(chrootHostsPath, 'utf8'); // Docker bridge gateway resolution may succeed or fail in test env, @@ -617,7 +609,7 @@ describe('agent service', () => { it('should mount custom chroot-hosts even without enableHostAccess', () => { const config = { - ...mockConfig, + ...getConfig(), enableHostAccess: false }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -648,15 +640,15 @@ describe('agent service', () => { }); const config = { - ...mockConfig, + ...getConfig(), allowedDomains: ['github.com', 'npmjs.org', '*.wildcard.com'], }; generateDockerCompose(config, mockNetworkConfig); // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(mockConfig.workDir).find(d => d.startsWith('chroot-')); + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${mockConfig.workDir}/${chrootDir}/hosts`; + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; expect(fs.existsSync(chrootHostsPath)).toBe(true); const content = fs.readFileSync(chrootHostsPath, 'utf8'); @@ -677,16 +669,16 @@ describe('agent service', () => { }); const config = { - ...mockConfig, + ...getConfig(), allowedDomains: ['unreachable.tailnet.example'], }; // Should not throw even if resolution fails generateDockerCompose(config, mockNetworkConfig); // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(mockConfig.workDir).find(d => d.startsWith('chroot-')); + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${mockConfig.workDir}/${chrootDir}/hosts`; + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; expect(fs.existsSync(chrootHostsPath)).toBe(true); const content = fs.readFileSync(chrootHostsPath, 'utf8'); @@ -709,15 +701,15 @@ describe('agent service', () => { }); const config = { - ...mockConfig, + ...getConfig(), allowedDomains: ['localhost'], // localhost is already in /etc/hosts }; generateDockerCompose(config, mockNetworkConfig); // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(mockConfig.workDir).find(d => d.startsWith('chroot-')); + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${mockConfig.workDir}/${chrootDir}/hosts`; + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; const content = fs.readFileSync(chrootHostsPath, 'utf8'); // Count occurrences of 'localhost' - should only be the original entries, not duplicated diff --git a/src/services/agent-volumes-security.test.ts b/src/services/agent-volumes-security.test.ts index 108c96ff0..c626ac4d0 100644 --- a/src/services/agent-volumes-security.test.ts +++ b/src/services/agent-volumes-security.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { generateDockerCompose, mockNetworkConfig, useAgentVolumesTestConfig } from './service-test-setup.test-utils'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -8,19 +8,11 @@ import * as path from 'path'; // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); -let mockConfig: WrapperConfig; +const { getConfig } = useAgentVolumesTestConfig(); describe('agent service', () => { - useTempWorkDir( - baseConfig, - (config) => { - mockConfig = config; - }, - () => mockConfig - ); - it('should use selective mounts when no custom mounts specified', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -31,7 +23,7 @@ describe('agent service', () => { }); it('should hide Docker socket by default', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -42,37 +34,37 @@ describe('agent service', () => { describe('workDir tmpfs overlay (secrets protection)', () => { it('should hide workDir from agent container via tmpfs in normal mode', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const tmpfs = agent.tmpfs as string[]; // workDir should be hidden via tmpfs overlay to prevent reading docker-compose.yml - expect(tmpfs).toContainEqual(expect.stringContaining(mockConfig.workDir)); - expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs).toContainEqual(expect.stringContaining(getConfig().workDir)); + expect(tmpfs.some((t: string) => t.startsWith(`${getConfig().workDir}:`))).toBe(true); }); it('should hide workDir at both normal and /host paths (chroot always on)', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const tmpfs = agent.tmpfs as string[]; // Both /tmp/awf-test and /host/tmp/awf-test should be hidden - expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); - expect(tmpfs.some((t: string) => t.startsWith(`/host${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`${getConfig().workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`/host${getConfig().workDir}:`))).toBe(true); }); it('should still hide mcp-logs alongside workDir', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const tmpfs = agent.tmpfs as string[]; // Both mcp-logs and workDir should be hidden expect(tmpfs.some((t: string) => t.includes('/tmp/gh-aw/mcp-logs'))).toBe(true); - expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`${getConfig().workDir}:`))).toBe(true); }); it('should set secure tmpfs options (noexec, nosuid, size limit)', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const tmpfs = agent.tmpfs as string[]; @@ -88,7 +80,7 @@ describe('agent service', () => { it('should apply tmpfs overlay to custom workDir paths', () => { const customWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-workdir-')); const configWithCustomWorkDir = { - ...mockConfig, + ...getConfig(), workDir: customWorkDir, }; try { @@ -104,17 +96,17 @@ describe('agent service', () => { }); it('should include exactly 5 tmpfs mounts (mcp-logs + workDir both normal and /host, plus /host/dev/shm)', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const tmpfs = agent.tmpfs as string[]; expect(tmpfs).toHaveLength(5); // Normal paths expect(tmpfs.some((t: string) => t.includes('/tmp/gh-aw/mcp-logs:'))).toBe(true); - expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`${getConfig().workDir}:`))).toBe(true); // /host-prefixed paths (chroot always on) expect(tmpfs.some((t: string) => t.includes('/host/tmp/gh-aw/mcp-logs:'))).toBe(true); - expect(tmpfs.some((t: string) => t.startsWith(`/host${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`/host${getConfig().workDir}:`))).toBe(true); // Writable /dev/shm for POSIX semaphores (chroot makes /host/dev read-only) expect(tmpfs.some((t: string) => t.startsWith('/host/dev/shm:'))).toBe(true); }); diff --git a/src/services/agent-volumes-workspace.test.ts b/src/services/agent-volumes-workspace.test.ts index 938f59818..e7cc6212f 100644 --- a/src/services/agent-volumes-workspace.test.ts +++ b/src/services/agent-volumes-workspace.test.ts @@ -1,23 +1,15 @@ -import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; +import { generateDockerCompose, WrapperConfig, mockNetworkConfig, useAgentVolumesTestConfig } from './service-test-setup.test-utils'; // Create mock functions (must remain per-file — jest.mock() is hoisted before imports) // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); -let mockConfig: WrapperConfig; +const { getConfig } = useAgentVolumesTestConfig(); describe('agent service', () => { - useTempWorkDir( - baseConfig, - (config) => { - mockConfig = config; - }, - () => mockConfig - ); - it('should mount workspace directory under /host', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); const agent = result.services.agent; const volumes = agent.volumes as string[]; @@ -28,14 +20,14 @@ describe('agent service', () => { describe('containerWorkDir option', () => { it('should not set working_dir when containerWorkDir is not specified', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); expect(result.services.agent.working_dir).toBeUndefined(); }); it('should set working_dir when containerWorkDir is specified', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '/home/runner/work/repo/repo', }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -45,7 +37,7 @@ describe('agent service', () => { it('should set working_dir to /workspace when containerWorkDir is /workspace', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '/workspace', }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -55,7 +47,7 @@ describe('agent service', () => { it('should handle paths with special characters', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '/home/user/my-project with spaces', }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -65,7 +57,7 @@ describe('agent service', () => { it('should preserve working_dir alongside other agent service config', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '/custom/workdir', envAll: true, }; @@ -80,7 +72,7 @@ describe('agent service', () => { it('should handle empty string containerWorkDir by not setting working_dir', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '', }; const result = generateDockerCompose(config, mockNetworkConfig); @@ -91,7 +83,7 @@ describe('agent service', () => { it('should handle absolute paths correctly', () => { const config: WrapperConfig = { - ...mockConfig, + ...getConfig(), containerWorkDir: '/var/lib/app/data', }; const result = generateDockerCompose(config, mockNetworkConfig); diff --git a/src/services/service-test-setup.test-utils.ts b/src/services/service-test-setup.test-utils.ts index 2eb1adb82..f57f55c51 100644 --- a/src/services/service-test-setup.test-utils.ts +++ b/src/services/service-test-setup.test-utils.ts @@ -4,12 +4,42 @@ * Re-exports the common imports used across all service test files, so each * test file only needs a single import from this module for the shared pieces. * - * Note: `jest.mock('execa', ...)` along with the `mockConfig` let-binding and - * `useTempWorkDir()` call must remain in each individual test file. Jest hoists - * jest.mock() calls to the top of each file before imports are resolved, so the - * factory closure cannot reference variables from an imported module. + * Note: `jest.mock('execa', ...)` must remain in each individual test file. + * Jest hoists jest.mock() calls to the top of each file before imports are + * resolved, so the factory closure cannot reference variables from an imported + * module. */ export { generateDockerCompose } from '../compose-generator'; export type { WrapperConfig } from '../types'; export { baseConfig, mockNetworkConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.test-utils'; + +import type { WrapperConfig } from '../types'; +import { baseConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures.test-utils'; + +/** + * Encapsulates the repeated `let mockConfig` + `useTempWorkDir(baseConfig, …)` + * boilerplate shared by all `agent-volumes-*.test.ts` files. + * + * Call this at the top level of the test file (outside any `describe` block) + * and use the returned `getConfig` accessor inside your tests. + * + * @example + * ```ts + * const { getConfig } = useAgentVolumesTestConfig(); + * describe('agent service', () => { + * it('…', () => { const result = generateDockerCompose(getConfig(), …); }); + * }); + * ``` + */ +export function useAgentVolumesTestConfig(): { getConfig: () => WrapperConfig } { + let mockConfig: WrapperConfig; + useTempWorkDir( + baseConfig, + (c) => { + mockConfig = c; + }, + () => mockConfig, + ); + return { getConfig: () => mockConfig }; +} From ebe6a02d426475ad127d0f9ba0e77e5d031b0062 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:01:36 +0000 Subject: [PATCH 3/3] test: address review feedback on agent volume setup helper --- src/services/agent-volumes-mounts.test.ts | 1252 ++++++++--------- src/services/service-test-setup.test-utils.ts | 13 +- 2 files changed, 636 insertions(+), 629 deletions(-) diff --git a/src/services/agent-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index 50d8f6cba..ecb407837 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -42,685 +42,685 @@ function withEnv(envPatch: Record, fn: () => void): describe('agent service', () => { it('should mount required volumes in agent container (default behavior)', () => { - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - - // Default: selective mounting (no blanket /:/host:rw) - expect(volumes).not.toContain('/:/host:rw'); - expect(volumes).toContain('/tmp:/tmp:rw'); - expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); - // Should include home directory mount - expect(volumes.some((v: string) => v.includes(process.env.HOME || '/root'))).toBe(true); - // Should include credential hiding mounts - expect(volumes.some((v: string) => v.includes('/dev/null') && v.includes('.docker/config.json'))).toBe(true); - }); - - it('should use custom volume mounts when specified', () => { - const configWithMounts = { - ...getConfig(), - volumeMounts: ['/workspace:/workspace:ro', '/data:/data:rw'] - }; - const result = generateDockerCompose(configWithMounts, mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - - // Should NOT include blanket /:/host:rw mount - expect(volumes).not.toContain('/:/host:rw'); - - // Should include custom mounts (prefixed with /host for chroot visibility) - expect(volumes).toContain('/workspace:/host/workspace:ro'); - expect(volumes).toContain('/data:/host/data:rw'); - - // Should still include essential mounts - expect(volumes).toContain('/tmp:/tmp:rw'); - expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); - }); - - it('should apply dockerHostPathPrefix to bind-mount source paths', () => { - const configWithPrefix = { - ...getConfig(), - dockerHostPathPrefix: '/daemon-root', - volumeMounts: ['/workspace:/workspace:ro'], - }; - const result = generateDockerCompose(configWithPrefix, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - expect(volumes).toContain('/daemon-root/tmp:/tmp:rw'); - expect(volumes).toContain('/daemon-root/usr:/host/usr:ro'); - expect(volumes).toContain('/daemon-root/etc/passwd:/host/etc/passwd:ro'); - expect(volumes).toContain('/daemon-root/workspace:/host/workspace:ro'); - expect(volumes).toContain('/dev/null:/host/var/run/docker.sock:ro'); - expect(volumes).toContain('/dev/null:/host/run/docker.sock:ro'); - expect(volumes.some((v: string) => v.startsWith(`/daemon-root${getConfig().workDir}/chroot-`) && v.endsWith(':/host/etc/hosts:ro'))).toBe(true); - - // Kernel virtual filesystems should NOT be prefixed — they are daemon-local - expect(volumes).toContain('/dev:/host/dev:ro'); - expect(volumes).toContain('/sys:/host/sys:ro'); - expect(volumes).not.toContain('/daemon-root/dev:/host/dev:ro'); - expect(volumes).not.toContain('/daemon-root/sys:/host/sys:ro'); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Default: selective mounting (no blanket /:/host:rw) + expect(volumes).not.toContain('/:/host:rw'); + expect(volumes).toContain('/tmp:/tmp:rw'); + expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); + // Should include home directory mount + expect(volumes.some((v: string) => v.includes(process.env.HOME || '/root'))).toBe(true); + // Should include credential hiding mounts + expect(volumes.some((v: string) => v.includes('/dev/null') && v.includes('.docker/config.json'))).toBe(true); + }); + + it('should use custom volume mounts when specified', () => { + const configWithMounts = { + ...getConfig(), + volumeMounts: ['/workspace:/workspace:ro', '/data:/data:rw'], + }; + const result = generateDockerCompose(configWithMounts, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Should NOT include blanket /:/host:rw mount + expect(volumes).not.toContain('/:/host:rw'); + + // Should include custom mounts (prefixed with /host for chroot visibility) + expect(volumes).toContain('/workspace:/host/workspace:ro'); + expect(volumes).toContain('/data:/host/data:rw'); + + // Should still include essential mounts + expect(volumes).toContain('/tmp:/tmp:rw'); + expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); + }); + + it('should apply dockerHostPathPrefix to bind-mount source paths', () => { + const configWithPrefix = { + ...getConfig(), + dockerHostPathPrefix: '/daemon-root', + volumeMounts: ['/workspace:/workspace:ro'], + }; + const result = generateDockerCompose(configWithPrefix, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain('/daemon-root/tmp:/tmp:rw'); + expect(volumes).toContain('/daemon-root/usr:/host/usr:ro'); + expect(volumes).toContain('/daemon-root/etc/passwd:/host/etc/passwd:ro'); + expect(volumes).toContain('/daemon-root/workspace:/host/workspace:ro'); + expect(volumes).toContain('/dev/null:/host/var/run/docker.sock:ro'); + expect(volumes).toContain('/dev/null:/host/run/docker.sock:ro'); + expect(volumes.some((v: string) => v.startsWith(`/daemon-root${getConfig().workDir}/chroot-`) && v.endsWith(':/host/etc/hosts:ro'))).toBe(true); + + // Kernel virtual filesystems should NOT be prefixed — they are daemon-local + expect(volumes).toContain('/dev:/host/dev:ro'); + expect(volumes).toContain('/sys:/host/sys:ro'); + expect(volumes).not.toContain('/daemon-root/dev:/host/dev:ro'); + expect(volumes).not.toContain('/daemon-root/sys:/host/sys:ro'); + }); + + it('should normalize trailing slash in dockerHostPathPrefix', () => { + const configWithPrefix = { + ...getConfig(), + dockerHostPathPrefix: '/daemon-root/', + }; + const result = generateDockerCompose(configWithPrefix, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain('/daemon-root/tmp:/tmp:rw'); + }); + + it('should auto-stage the ARC/DinD manual bootstrap files under a shared /tmp docker-host-path-prefix', () => { + const originalPath = process.env.PATH; + const sharedTmpPrefix = fs.mkdtempSync(path.join('/tmp', 'gh-aw-')); + const fakeBinDir = path.join(getConfig().workDir, 'fake-bin'); + fs.mkdirSync(fakeBinDir, { recursive: true }); + const fakeCopilotPath = path.join(fakeBinDir, 'copilot'); + fs.writeFileSync(fakeCopilotPath, '#!/bin/sh\necho copilot\n', { mode: 0o755 }); + process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath || ''}`; + mockExecaSync.mockImplementation((command: string, args?: string[]) => { + if (command === 'docker' && args?.[0] === 'network' && args[1] === 'inspect') { + return { stdout: '172.17.0.1', stderr: '', exitCode: 0 }; + } + throw new Error('Not found'); }); - it('should normalize trailing slash in dockerHostPathPrefix', () => { - const configWithPrefix = { + try { + const configWithTmpPrefix = { ...getConfig(), - dockerHostPathPrefix: '/daemon-root/', + dockerHostPathPrefix: sharedTmpPrefix, + agentCommand: 'copilot --version', + enableHostAccess: true, }; - const result = generateDockerCompose(configWithPrefix, mockNetworkConfig); + const result = generateDockerCompose(configWithTmpPrefix, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; - - expect(volumes).toContain('/daemon-root/tmp:/tmp:rw'); - }); - - it('should auto-stage the ARC/DinD manual bootstrap files under a shared /tmp docker-host-path-prefix', () => { - const originalPath = process.env.PATH; - const sharedTmpPrefix = fs.mkdtempSync(path.join('/tmp', 'gh-aw-')); - const fakeBinDir = path.join(getConfig().workDir, 'fake-bin'); - fs.mkdirSync(fakeBinDir, { recursive: true }); - const fakeCopilotPath = path.join(fakeBinDir, 'copilot'); - fs.writeFileSync(fakeCopilotPath, '#!/bin/sh\necho copilot\n', { mode: 0o755 }); - process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath || ''}`; - mockExecaSync.mockImplementation((command: string, args?: string[]) => { - if (command === 'docker' && args?.[0] === 'network' && args[1] === 'inspect') { - return { stdout: '172.17.0.1', stderr: '', exitCode: 0 }; - } - throw new Error('Not found'); - }); - - try { - const configWithTmpPrefix = { + const stageRoot = path.join(sharedTmpPrefix, 'awf-docker-host-stage'); + const stagedBinaryPath = path.join(stageRoot, 'bin/copilot'); + const hostsVolume = volumes.find((v: string) => v.endsWith(':/host/etc/hosts:ro')); + const passwdVolume = volumes.find((v: string) => v.endsWith(':/host/etc/passwd:ro')); + const groupVolume = volumes.find((v: string) => v.endsWith(':/host/etc/group:ro')); + + // passwd and group are staged under stageRoot — either at etc/passwd (direct copy) + // or identity-XXXXX/passwd (synthesized when host UID not found in staged file) + expect(passwdVolume).toBeDefined(); + expect(passwdVolume?.startsWith(stageRoot)).toBe(true); + expect(groupVolume).toBeDefined(); + expect(groupVolume?.startsWith(stageRoot)).toBe(true); + expect(volumes).toContain(`${stagedBinaryPath}:/tmp/awf-runner-bin/copilot:ro`); + expect(hostsVolume).toBeDefined(); + expect(hostsVolume?.startsWith(`${stageRoot}/chroot-`)).toBe(true); + + const stagedPasswdPath = passwdVolume!.split(':')[0]; + const stagedGroupPath = groupVolume!.split(':')[0]; + // Staged passwd must contain the host UID (either copied or synthesized) + const { getSafeHostUid } = jest.requireActual('../host-identity') as typeof import('../host-identity'); + const uid = getSafeHostUid(); + expect(fs.readFileSync(stagedPasswdPath, 'utf8')).toMatch(new RegExp(`^[^:]*:[^:]*:${uid}:`, 'm')); + expect(fs.existsSync(stagedGroupPath)).toBe(true); + expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('echo copilot'); + expect(fs.statSync(stagedBinaryPath).mode & 0o111).not.toBe(0); + + const stagedHostsPath = hostsVolume?.split(':', 1)[0]; + expect(stagedHostsPath).toBeDefined(); + expect(fs.existsSync(stagedHostsPath || '')).toBe(true); + expect(fs.readFileSync(stagedHostsPath || '', 'utf8')).toContain('172.17.0.1\thost.docker.internal'); + + expect(volumes.some((v: string) => v.includes(`${sharedTmpPrefix}/arc-etc/`))).toBe(false); + expect(volumes.some((v: string) => v.includes(`${sharedTmpPrefix}/arc-tools/`))).toBe(false); + } finally { + mockExecaSync.mockReset(); + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; + } + fs.rmSync(sharedTmpPrefix, { recursive: true, force: true }); + } + }); + + it('should skip non-executable PATH candidates when staging the runner binary', () => { + const originalPath = process.env.PATH; + const nonExecutableDir = path.join(getConfig().workDir, 'fake-bin-nonexec'); + const executableDir = path.join(getConfig().workDir, 'fake-bin-exec'); + fs.mkdirSync(nonExecutableDir, { recursive: true }); + fs.mkdirSync(executableDir, { recursive: true }); + fs.writeFileSync(path.join(nonExecutableDir, 'copilot'), '#!/bin/sh\necho wrong\n', { mode: 0o644 }); + fs.writeFileSync(path.join(executableDir, 'copilot'), '#!/bin/sh\necho correct\n', { mode: 0o755 }); + process.env.PATH = `${nonExecutableDir}${path.delimiter}${executableDir}${path.delimiter}${originalPath || ''}`; + + try { + generateDockerCompose( + { ...getConfig(), - dockerHostPathPrefix: sharedTmpPrefix, + dockerHostPathPrefix: '/tmp/gh-aw', agentCommand: 'copilot --version', - enableHostAccess: true, - }; - const result = generateDockerCompose(configWithTmpPrefix, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - const stageRoot = path.join(sharedTmpPrefix, 'awf-docker-host-stage'); - const stagedBinaryPath = path.join(stageRoot, 'bin/copilot'); - const hostsVolume = volumes.find((v: string) => v.endsWith(':/host/etc/hosts:ro')); - const passwdVolume = volumes.find((v: string) => v.endsWith(':/host/etc/passwd:ro')); - const groupVolume = volumes.find((v: string) => v.endsWith(':/host/etc/group:ro')); - - // passwd and group are staged under stageRoot — either at etc/passwd (direct copy) - // or identity-XXXXX/passwd (synthesized when host UID not found in staged file) - expect(passwdVolume).toBeDefined(); - expect(passwdVolume?.startsWith(stageRoot)).toBe(true); - expect(groupVolume).toBeDefined(); - expect(groupVolume?.startsWith(stageRoot)).toBe(true); - expect(volumes).toContain(`${stagedBinaryPath}:/tmp/awf-runner-bin/copilot:ro`); - expect(hostsVolume).toBeDefined(); - expect(hostsVolume?.startsWith(`${stageRoot}/chroot-`)).toBe(true); - - const stagedPasswdPath = passwdVolume!.split(':')[0]; - const stagedGroupPath = groupVolume!.split(':')[0]; - // Staged passwd must contain the host UID (either copied or synthesized) - const { getSafeHostUid } = jest.requireActual('../host-identity') as typeof import('../host-identity'); - const uid = getSafeHostUid(); - expect(fs.readFileSync(stagedPasswdPath, 'utf8')).toMatch(new RegExp(`^[^:]*:[^:]*:${uid}:`, 'm')); - expect(fs.existsSync(stagedGroupPath)).toBe(true); - expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('echo copilot'); - expect(fs.statSync(stagedBinaryPath).mode & 0o111).not.toBe(0); - - const stagedHostsPath = hostsVolume?.split(':', 1)[0]; - expect(stagedHostsPath).toBeDefined(); - expect(fs.existsSync(stagedHostsPath || '')).toBe(true); - expect(fs.readFileSync(stagedHostsPath || '', 'utf8')).toContain('172.17.0.1\thost.docker.internal'); - - expect(volumes.some((v: string) => v.includes(`${sharedTmpPrefix}/arc-etc/`))).toBe(false); - expect(volumes.some((v: string) => v.includes(`${sharedTmpPrefix}/arc-tools/`))).toBe(false); - } finally { - mockExecaSync.mockReset(); - if (originalPath !== undefined) { - process.env.PATH = originalPath; - } else { - delete process.env.PATH; - } - fs.rmSync(sharedTmpPrefix, { recursive: true, force: true }); - } - }); - - it('should skip non-executable PATH candidates when staging the runner binary', () => { - const originalPath = process.env.PATH; - const nonExecutableDir = path.join(getConfig().workDir, 'fake-bin-nonexec'); - const executableDir = path.join(getConfig().workDir, 'fake-bin-exec'); - fs.mkdirSync(nonExecutableDir, { recursive: true }); - fs.mkdirSync(executableDir, { recursive: true }); - fs.writeFileSync(path.join(nonExecutableDir, 'copilot'), '#!/bin/sh\necho wrong\n', { mode: 0o644 }); - fs.writeFileSync(path.join(executableDir, 'copilot'), '#!/bin/sh\necho correct\n', { mode: 0o755 }); - process.env.PATH = `${nonExecutableDir}${path.delimiter}${executableDir}${path.delimiter}${originalPath || ''}`; - - try { - generateDockerCompose( - { - ...getConfig(), - dockerHostPathPrefix: '/tmp/gh-aw', - agentCommand: 'copilot --version', - }, - mockNetworkConfig, - ); - - const stagedBinaryPath = '/tmp/gh-aw/awf-docker-host-stage/bin/copilot'; - expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('correct'); - } finally { - if (originalPath !== undefined) { - process.env.PATH = originalPath; - } else { - delete process.env.PATH; - } - } - }); + }, + mockNetworkConfig, + ); - it('should prefer an explicit command path when staging the runner binary', () => { - const originalPath = process.env.PATH; - const fakeBinDir = path.join(getConfig().workDir, 'fake-bin-path'); - const explicitBinDir = path.join(getConfig().workDir, 'explicit-bin'); - fs.mkdirSync(fakeBinDir, { recursive: true }); - fs.mkdirSync(explicitBinDir, { recursive: true }); - fs.writeFileSync(path.join(fakeBinDir, 'copilot'), '#!/bin/sh\necho path\n', { mode: 0o755 }); - const explicitBinaryPath = path.join(explicitBinDir, 'copilot'); - fs.writeFileSync(explicitBinaryPath, '#!/bin/sh\necho explicit\n', { mode: 0o755 }); - process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath || ''}`; - - try { - generateDockerCompose( - { - ...getConfig(), - dockerHostPathPrefix: '/tmp/gh-aw', - agentCommand: `${explicitBinaryPath} --version`, - }, - mockNetworkConfig, - ); - - const stagedBinaryPath = '/tmp/gh-aw/awf-docker-host-stage/bin/copilot'; - expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('explicit'); - } finally { - if (originalPath !== undefined) { - process.env.PATH = originalPath; - } else { - delete process.env.PATH; - } + const stagedBinaryPath = '/tmp/gh-aw/awf-docker-host-stage/bin/copilot'; + expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('correct'); + } finally { + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; } - }); - - it('should leave /etc/passwd and /etc/group unprefixed in shared /tmp staging fallback mode', () => { - expect(applyHostPathPrefixToVolumes(['/etc/passwd:/host/etc/passwd:ro'], '/tmp/gh-aw')) - .toEqual(['/etc/passwd:/host/etc/passwd:ro']); - expect(applyHostPathPrefixToVolumes(['/etc/group:/host/etc/group:ro'], '/tmp/gh-aw')) - .toEqual(['/etc/group:/host/etc/group:ro']); - }); - - it('should prune stale staged chroot hosts directories under shared /tmp docker-host-path-prefix', () => { - const stageRoot = '/tmp/gh-aw/awf-docker-host-stage'; - const staleDir = path.join(stageRoot, 'chroot-stale'); - fs.mkdirSync(staleDir, { recursive: true }); - fs.writeFileSync(path.join(staleDir, 'hosts'), '127.0.0.1 localhost\n'); - const staleTime = new Date(Date.now() - (25 * 60 * 60 * 1000)); - fs.utimesSync(staleDir, staleTime, staleTime); - fs.utimesSync(path.join(staleDir, 'hosts'), staleTime, staleTime); - - const result = generateDockerCompose( + } + }); + + it('should prefer an explicit command path when staging the runner binary', () => { + const originalPath = process.env.PATH; + const fakeBinDir = path.join(getConfig().workDir, 'fake-bin-path'); + const explicitBinDir = path.join(getConfig().workDir, 'explicit-bin'); + fs.mkdirSync(fakeBinDir, { recursive: true }); + fs.mkdirSync(explicitBinDir, { recursive: true }); + fs.writeFileSync(path.join(fakeBinDir, 'copilot'), '#!/bin/sh\necho path\n', { mode: 0o755 }); + const explicitBinaryPath = path.join(explicitBinDir, 'copilot'); + fs.writeFileSync(explicitBinaryPath, '#!/bin/sh\necho explicit\n', { mode: 0o755 }); + process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath || ''}`; + + try { + generateDockerCompose( { ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw', + agentCommand: `${explicitBinaryPath} --version`, }, mockNetworkConfig, ); - const volumes = result.services.agent.volumes as string[]; - - expect(fs.existsSync(staleDir)).toBe(false); - expect( - volumes.some((v: string) => v.startsWith('/tmp/gh-aw/awf-docker-host-stage/chroot-') && v.endsWith(':/host/etc/hosts:ro')) - ).toBe(true); - }); - it('should mount api-proxy health-check script when api-proxy is enabled', () => { - const configWithApiProxy = { + const stagedBinaryPath = '/tmp/gh-aw/awf-docker-host-stage/bin/copilot'; + expect(fs.readFileSync(stagedBinaryPath, 'utf8')).toContain('explicit'); + } finally { + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; + } + } + }); + + it('should leave /etc/passwd and /etc/group unprefixed in shared /tmp staging fallback mode', () => { + expect(applyHostPathPrefixToVolumes(['/etc/passwd:/host/etc/passwd:ro'], '/tmp/gh-aw')) + .toEqual(['/etc/passwd:/host/etc/passwd:ro']); + expect(applyHostPathPrefixToVolumes(['/etc/group:/host/etc/group:ro'], '/tmp/gh-aw')) + .toEqual(['/etc/group:/host/etc/group:ro']); + }); + + it('should prune stale staged chroot hosts directories under shared /tmp docker-host-path-prefix', () => { + const stageRoot = '/tmp/gh-aw/awf-docker-host-stage'; + const staleDir = path.join(stageRoot, 'chroot-stale'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'hosts'), '127.0.0.1 localhost\n'); + const staleTime = new Date(Date.now() - (25 * 60 * 60 * 1000)); + fs.utimesSync(staleDir, staleTime, staleTime); + fs.utimesSync(path.join(staleDir, 'hosts'), staleTime, staleTime); + + const result = generateDockerCompose( + { ...getConfig(), - enableApiProxy: true, - }; - const result = generateDockerCompose(configWithApiProxy, mockNetworkConfig); + dockerHostPathPrefix: '/tmp/gh-aw', + }, + mockNetworkConfig, + ); + const volumes = result.services.agent.volumes as string[]; + + expect(fs.existsSync(staleDir)).toBe(false); + expect( + volumes.some((v: string) => v.startsWith('/tmp/gh-aw/awf-docker-host-stage/chroot-') && v.endsWith(':/host/etc/hosts:ro')) + ).toBe(true); + }); + + it('should mount api-proxy health-check script when api-proxy is enabled', () => { + const configWithApiProxy = { + ...getConfig(), + enableApiProxy: true, + }; + const result = generateDockerCompose(configWithApiProxy, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContainEqual(expect.stringMatching(/containers\/agent\/api-proxy-health-check\.sh:\/usr\/local\/bin\/api-proxy-health-check\.sh:ro$/)); + }); + + it('should apply dockerHostPathPrefix to api-proxy health-check script mount', () => { + const configWithApiProxyAndPrefix = { + ...getConfig(), + enableApiProxy: true, + dockerHostPathPrefix: '/daemon-root', + }; + const result = generateDockerCompose(configWithApiProxyAndPrefix, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContainEqual(expect.stringMatching(/^\/daemon-root.*containers\/agent\/api-proxy-health-check\.sh:\/usr\/local\/bin\/api-proxy-health-check\.sh:ro$/)); + }); + + it('should handle malformed volume mount without colon as fallback', () => { + const configWithBadMount = { + ...getConfig(), + volumeMounts: ['no-colon-here'] + }; + const result = generateDockerCompose(configWithBadMount, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + // Malformed mount should be added as-is (fallback) + expect(volumes).toContain('no-colon-here'); + }); + + it('should reject staged target paths that escape the docker-host staging root', () => { + const sourceFile = path.join(getConfig().workDir, 'stage-source.txt'); + fs.writeFileSync(sourceFile, 'stage me'); + + const stagedPath = stageHostFile( + { ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw' }, + sourceFile, + '../escaped.txt', + ); + + expect(stagedPath).toBeUndefined(); + expect(fs.existsSync('/tmp/gh-aw/escaped.txt')).toBe(false); + }); + + it('should use selective mounts by default', () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Should NOT include blanket /:/host:rw mount + expect(volumes).not.toContain('/:/host:rw'); + + // Should include system paths (read-only) + expect(volumes).toContain('/usr:/host/usr:ro'); + expect(volumes).toContain('/bin:/host/bin:ro'); + expect(volumes).toContain('/sbin:/host/sbin:ro'); + expect(volumes).toContain('/lib:/host/lib:ro'); + expect(volumes).toContain('/lib64:/host/lib64:ro'); + expect(volumes).toContain('/opt:/host/opt:ro'); + + // Should include special filesystems (read-only) + // NOTE: /proc is NOT bind-mounted. Instead, a container-scoped procfs is mounted + // at /host/proc via 'mount -t proc' in entrypoint.sh (requires SYS_ADMIN, which + // is dropped before user code). This provides dynamic /proc/self/exe resolution. + expect(volumes).not.toContain('/proc:/host/proc:ro'); + expect(volumes).not.toContain('/proc/self:/host/proc/self:ro'); + expect(volumes).toContain('/sys:/host/sys:ro'); + expect(volumes).toContain('/dev:/host/dev:ro'); + + // Should include /etc subdirectories (read-only) + expect(volumes).toContain('/etc/ssl:/host/etc/ssl:ro'); + expect(volumes).toContain('/etc/ca-certificates:/host/etc/ca-certificates:ro'); + expect(volumes).toContain('/etc/alternatives:/host/etc/alternatives:ro'); + expect(volumes).toContain('/etc/ld.so.cache:/host/etc/ld.so.cache:ro'); + // /etc/hosts is always a custom hosts file in a secure chroot temp dir (for pre-resolved domains) + const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); + expect(hostsVolume).toBeDefined(); + expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); + + // Should still include essential mounts + expect(volumes).toContain('/tmp:/tmp:rw'); + expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); + }); + + it('should expose Docker socket when enableDind is true', () => { + const dindConfig = { ...getConfig(), enableDind: true }; + const result = generateDockerCompose(dindConfig, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Docker socket should be mounted read-write, not hidden + expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); + expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); + // Should NOT have /dev/null mounts + expect(volumes).not.toContain('/dev/null:/host/var/run/docker.sock:ro'); + expect(volumes).not.toContain('/dev/null:/host/run/docker.sock:ro'); + }); + + it('should expose the Unix DOCKER_HOST socket path when enableDind is true', () => { + withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { + const dindConfig = { ...getConfig(), enableDind: true }; + const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; - expect(volumes).toContainEqual(expect.stringMatching(/containers\/agent\/api-proxy-health-check\.sh:\/usr\/local\/bin\/api-proxy-health-check\.sh:ro$/)); + expect(volumes).toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); + expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); + expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); }); + }); - it('should apply dockerHostPathPrefix to api-proxy health-check script mount', () => { - const configWithApiProxyAndPrefix = { + it('should prefer awfDockerHost over DOCKER_HOST when enableDind is true', () => { + withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { + const dindConfig = { ...getConfig(), - enableApiProxy: true, - dockerHostPathPrefix: '/daemon-root', + enableDind: true, + awfDockerHost: 'unix:///run/user/1000/docker.sock', }; - const result = generateDockerCompose(configWithApiProxyAndPrefix, mockNetworkConfig); + const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; + const env = result.services.agent.environment as Record; - expect(volumes).toContainEqual(expect.stringMatching(/^\/daemon-root.*containers\/agent\/api-proxy-health-check\.sh:\/usr\/local\/bin\/api-proxy-health-check\.sh:ro$/)); + expect(volumes).toContain('/run/user/1000/docker.sock:/host/run/user/1000/docker.sock:rw'); + expect(volumes).not.toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); + expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); }); + }); - it('should handle malformed volume mount without colon as fallback', () => { - const configWithBadMount = { + it('should set agent DOCKER_HOST from awfDockerHost when enableDind is true and host DOCKER_HOST is unset', () => { + withEnv({ DOCKER_HOST: undefined }, () => { + const dindConfig = { ...getConfig(), - volumeMounts: ['no-colon-here'] + enableDind: true, + awfDockerHost: 'unix:///run/user/1000/docker.sock', }; - const result = generateDockerCompose(configWithBadMount, mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - // Malformed mount should be added as-is (fallback) - expect(volumes).toContain('no-colon-here'); - }); - - it('should reject staged target paths that escape the docker-host staging root', () => { - const sourceFile = path.join(getConfig().workDir, 'stage-source.txt'); - fs.writeFileSync(sourceFile, 'stage me'); - - const stagedPath = stageHostFile( - { ...getConfig(), dockerHostPathPrefix: '/tmp/gh-aw' }, - sourceFile, - '../escaped.txt', - ); - - expect(stagedPath).toBeUndefined(); - expect(fs.existsSync('/tmp/gh-aw/escaped.txt')).toBe(false); - }); - - it('should use selective mounts by default', () => { - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - - // Should NOT include blanket /:/host:rw mount - expect(volumes).not.toContain('/:/host:rw'); - - // Should include system paths (read-only) - expect(volumes).toContain('/usr:/host/usr:ro'); - expect(volumes).toContain('/bin:/host/bin:ro'); - expect(volumes).toContain('/sbin:/host/sbin:ro'); - expect(volumes).toContain('/lib:/host/lib:ro'); - expect(volumes).toContain('/lib64:/host/lib64:ro'); - expect(volumes).toContain('/opt:/host/opt:ro'); - - // Should include special filesystems (read-only) - // NOTE: /proc is NOT bind-mounted. Instead, a container-scoped procfs is mounted - // at /host/proc via 'mount -t proc' in entrypoint.sh (requires SYS_ADMIN, which - // is dropped before user code). This provides dynamic /proc/self/exe resolution. - expect(volumes).not.toContain('/proc:/host/proc:ro'); - expect(volumes).not.toContain('/proc/self:/host/proc/self:ro'); - expect(volumes).toContain('/sys:/host/sys:ro'); - expect(volumes).toContain('/dev:/host/dev:ro'); - - // Should include /etc subdirectories (read-only) - expect(volumes).toContain('/etc/ssl:/host/etc/ssl:ro'); - expect(volumes).toContain('/etc/ca-certificates:/host/etc/ca-certificates:ro'); - expect(volumes).toContain('/etc/alternatives:/host/etc/alternatives:ro'); - expect(volumes).toContain('/etc/ld.so.cache:/host/etc/ld.so.cache:ro'); - // /etc/hosts is always a custom hosts file in a secure chroot temp dir (for pre-resolved domains) - const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); - expect(hostsVolume).toBeDefined(); - expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); - - // Should still include essential mounts - expect(volumes).toContain('/tmp:/tmp:rw'); - expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); - }); - - it('should expose Docker socket when enableDind is true', () => { - const dindConfig = { ...getConfig(), enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - - // Docker socket should be mounted read-write, not hidden - expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); - expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); - // Should NOT have /dev/null mounts - expect(volumes).not.toContain('/dev/null:/host/var/run/docker.sock:ro'); - expect(volumes).not.toContain('/dev/null:/host/run/docker.sock:ro'); - }); - - it('should expose the Unix DOCKER_HOST socket path when enableDind is true', () => { - withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { - const dindConfig = { ...getConfig(), enableDind: true }; - const result = generateDockerCompose(dindConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; + const volumes = result.services.agent.volumes as string[]; + const env = result.services.agent.environment as Record; - expect(volumes).toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); - expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); - expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); - }); + expect(volumes).toContain('/run/user/1000/docker.sock:/host/run/user/1000/docker.sock:rw'); + expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); + expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); + expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); }); + }); - it('should prefer awfDockerHost over DOCKER_HOST when enableDind is true', () => { - withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { - const dindConfig = { - ...getConfig(), - enableDind: true, - awfDockerHost: 'unix:///run/user/1000/docker.sock', - }; - const result = generateDockerCompose(dindConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - const env = result.services.agent.environment as Record; + it('should warn and fall back to the default socket for an invalid Unix DOCKER_HOST path', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); - expect(volumes).toContain('/run/user/1000/docker.sock:/host/run/user/1000/docker.sock:rw'); - expect(volumes).not.toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); - expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); - }); - }); - - it('should set agent DOCKER_HOST from awfDockerHost when enableDind is true and host DOCKER_HOST is unset', () => { - withEnv({ DOCKER_HOST: undefined }, () => { - const dindConfig = { - ...getConfig(), - enableDind: true, - awfDockerHost: 'unix:///run/user/1000/docker.sock', - }; + try { + withEnv({ DOCKER_HOST: 'unix://relative/path' }, () => { + const dindConfig = { ...getConfig(), enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; - const env = result.services.agent.environment as Record; - expect(volumes).toContain('/run/user/1000/docker.sock:/host/run/user/1000/docker.sock:rw'); - expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); - expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); - expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); + expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); + expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); + expect(volumes).not.toContain('relative/path:/hostrelative/path:rw'); + expect(warnSpy).toHaveBeenCalledWith('Ignoring invalid unix Docker host path: unix://relative/path'); }); - }); - - it('should warn and fall back to the default socket for an invalid Unix DOCKER_HOST path', () => { - const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); - - try { - withEnv({ DOCKER_HOST: 'unix://relative/path' }, () => { - const dindConfig = { ...getConfig(), enableDind: true }; - const result = generateDockerCompose(dindConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); - expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); - expect(volumes).not.toContain('relative/path:/hostrelative/path:rw'); - expect(warnSpy).toHaveBeenCalledWith('Ignoring invalid unix Docker host path: unix://relative/path'); - }); - } finally { - warnSpy.mockRestore(); - } - }); - - it('should mount Rust toolchain, Node/npm caches, and CLI state directories', () => { - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; - - const homeDir = process.env.HOME || '/root'; - // Rust toolchain directories - expect(volumes).toContain(`${homeDir}/.cargo:/host${homeDir}/.cargo:rw`); - expect(volumes).toContain(`${homeDir}/.rustup:/host${homeDir}/.rustup:rw`); - // npm cache - expect(volumes).toContain(`${homeDir}/.npm:/host${homeDir}/.npm:rw`); - // nvm-managed Node.js cache/installations - expect(volumes).toContain(`${homeDir}/.nvm:/host${homeDir}/.nvm:rw`); - // CLI state directories - expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`); - expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`); - // ~/.gemini is NOT mounted when geminiApiKey is absent (fixes suspicious log in Copilot runs) - expect(volumes).not.toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); - // ~/.copilot is only mounted if it already exists on the host - if (fs.existsSync(path.join(homeDir, '.copilot'))) { - expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`); - } - // session-state and logs are always overlaid from AWF workDir - expect(volumes).toContain(`${getConfig().workDir}/agent-session-state:/host${homeDir}/.copilot/session-state:rw`); - expect(volumes).toContain(`${getConfig().workDir}/agent-logs:/host${homeDir}/.copilot/logs:rw`); - }); - - it('should mount ~/.gemini when geminiApiKey is configured', () => { - const configWithGemini = { ...getConfig(), geminiApiKey: 'AIza-test-gemini-key' }; - const result = generateDockerCompose(configWithGemini, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - const homeDir = process.env.HOME || '/root'; - expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); - }); - - it('should mount self-hosted runner toolcache when present under HOME/work/_tool', () => { - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - - try { - withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { - const toolcacheDir = path.join(fakeHome, 'work', '_tool'); - fs.mkdirSync(toolcacheDir, { recursive: true }); - - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); - }); - } finally { - fs.rmSync(fakeHome, { recursive: true, force: true }); - } - }); - - it('should not mount HOME/work/_tool when it is a symlink', () => { - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - const symlinkTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-tool-target-')); - - try { - withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { - const workDir = path.join(fakeHome, 'work'); - fs.mkdirSync(workDir, { recursive: true }); - const toolcacheDir = path.join(workDir, '_tool'); - fs.symlinkSync(symlinkTarget, toolcacheDir); - - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); - }); - } finally { - fs.rmSync(fakeHome, { recursive: true, force: true }); - fs.rmSync(symlinkTarget, { recursive: true, force: true }); - } - }); - - it('should skip .copilot bind mount when directory does not exist at non-standard HOME path', () => { - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - - try { - withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { - const copilotDir = path.join(fakeHome, '.copilot'); - expect(fs.existsSync(copilotDir)).toBe(false); - - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - // Directory should NOT be auto-created (changed in #2114) - expect(fs.existsSync(copilotDir)).toBe(false); - // The blanket .copilot mount should be absent - expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); - // Optional self-hosted runner toolcache mount should also be absent - expect(volumes).not.toContain(`${fakeHome}/work/_tool:/host${fakeHome}/work/_tool:ro`); - // But session-state and logs overlays are always present - expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/session-state:rw`)); - expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/logs:rw`)); - }); - } finally { - fs.rmSync(fakeHome, { recursive: true, force: true }); - } - }); + } finally { + warnSpy.mockRestore(); + } + }); + + it('should mount Rust toolchain, Node/npm caches, and CLI state directories', () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + const homeDir = process.env.HOME || '/root'; + // Rust toolchain directories + expect(volumes).toContain(`${homeDir}/.cargo:/host${homeDir}/.cargo:rw`); + expect(volumes).toContain(`${homeDir}/.rustup:/host${homeDir}/.rustup:rw`); + // npm cache + expect(volumes).toContain(`${homeDir}/.npm:/host${homeDir}/.npm:rw`); + // nvm-managed Node.js cache/installations + expect(volumes).toContain(`${homeDir}/.nvm:/host${homeDir}/.nvm:rw`); + // CLI state directories + expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`); + expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`); + // ~/.gemini is NOT mounted when geminiApiKey is absent (fixes suspicious log in Copilot runs) + expect(volumes).not.toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); + // ~/.copilot is only mounted if it already exists on the host + if (fs.existsSync(path.join(homeDir, '.copilot'))) { + expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`); + } + // session-state and logs are always overlaid from AWF workDir + expect(volumes).toContain(`${getConfig().workDir}/agent-session-state:/host${homeDir}/.copilot/session-state:rw`); + expect(volumes).toContain(`${getConfig().workDir}/agent-logs:/host${homeDir}/.copilot/logs:rw`); + }); - it('should use sessionStateDir when specified for chroot mounts', () => { - const configWithSessionDir = { ...getConfig(), sessionStateDir: '/custom/session-state' }; - const result = generateDockerCompose(configWithSessionDir, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - const homeDir = process.env.HOME || '/root'; - expect(volumes).toContain(`/custom/session-state:/host${homeDir}/.copilot/session-state:rw`); - expect(volumes).toContain(`/custom/session-state:${homeDir}/.copilot/session-state:rw`); - }); + it('should mount ~/.gemini when geminiApiKey is configured', () => { + const configWithGemini = { ...getConfig(), geminiApiKey: 'AIza-test-gemini-key' }; + const result = generateDockerCompose(configWithGemini, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - it('should mount /tmp under /host for chroot temp scripts', () => { - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; + const homeDir = process.env.HOME || '/root'; + expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); + }); - // /tmp:/host/tmp:rw is required for entrypoint.sh to write command scripts - expect(volumes).toContain('/tmp:/host/tmp:rw'); - }); + it('should mount self-hosted runner toolcache when present under HOME/work/_tool', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - it('should mount /etc/passwd and /etc/group for user lookup in chroot mode', () => { - const result = generateDockerCompose(getConfig(), mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; + try { + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const toolcacheDir = path.join(fakeHome, 'work', '_tool'); + fs.mkdirSync(toolcacheDir, { recursive: true }); - // These are needed for getent/user lookup inside chroot - expect(volumes).toContain('/etc/passwd:/host/etc/passwd:ro'); - expect(volumes).toContain('/etc/group:/host/etc/group:ro'); - expect(volumes).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro'); - }); - - it('should mount read-only chroot-hosts when enableHostAccess is true', () => { - const config = { - ...getConfig(), - enableHostAccess: true - }; - const result = generateDockerCompose(config, mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - // Should mount a read-only copy of /etc/hosts with host.docker.internal pre-injected - const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); - expect(hostsVolume).toBeDefined(); - expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); - }); + expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + }); + } finally { + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); - it('should inject host.docker.internal into chroot-hosts file', () => { - const config = { - ...getConfig(), - enableHostAccess: true - }; - generateDockerCompose(config, mockNetworkConfig); - - // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); - expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; - expect(fs.existsSync(chrootHostsPath)).toBe(true); - const content = fs.readFileSync(chrootHostsPath, 'utf8'); - // Docker bridge gateway resolution may succeed or fail in test env, - // but the file should exist with at least localhost - expect(content).toContain('localhost'); - }); + it('should not mount HOME/work/_tool when it is a symlink', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + const symlinkTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-tool-target-')); - it('should mount custom chroot-hosts even without enableHostAccess', () => { - const config = { - ...getConfig(), - enableHostAccess: false - }; - const result = generateDockerCompose(config, mockNetworkConfig); - const agent = result.services.agent; - const volumes = agent.volumes as string[]; + try { + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const workDir = path.join(fakeHome, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + const toolcacheDir = path.join(workDir, '_tool'); + fs.symlinkSync(symlinkTarget, toolcacheDir); - // Should mount a custom hosts file in a secure chroot temp dir (for pre-resolved domains) - const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); - expect(hostsVolume).toBeDefined(); - expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); - }); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - it('should pre-resolve allowed domains into chroot-hosts file', () => { - // Mock getent to return a resolved IP for a test domain - mockExecaSync.mockImplementation((...args: any[]) => { - if (args[0] === 'getent' && args[1]?.[0] === 'hosts') { - const domain = args[1][1]; - if (domain === 'github.com') { - return { stdout: '140.82.121.4 github.com', stderr: '', exitCode: 0 }; - } - if (domain === 'npmjs.org') { - return { stdout: '104.16.22.35 npmjs.org', stderr: '', exitCode: 0 }; - } - throw new Error('Resolution failed'); - } - // For docker network inspect (host.docker.internal) - throw new Error('Not found'); + expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); }); + } finally { + fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(symlinkTarget, { recursive: true, force: true }); + } + }); - const config = { - ...getConfig(), - allowedDomains: ['github.com', 'npmjs.org', '*.wildcard.com'], - }; - generateDockerCompose(config, mockNetworkConfig); - - // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); - expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; - expect(fs.existsSync(chrootHostsPath)).toBe(true); - const content = fs.readFileSync(chrootHostsPath, 'utf8'); - - // Should contain pre-resolved domains - expect(content).toContain('140.82.121.4\tgithub.com'); - expect(content).toContain('104.16.22.35\tnpmjs.org'); - // Should NOT contain wildcard domains (can't be resolved) - expect(content).not.toContain('wildcard.com'); - - // Reset mock - mockExecaSync.mockReset(); - }); + it('should skip .copilot bind mount when directory does not exist at non-standard HOME path', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - it('should skip domains that fail to resolve during pre-resolution', () => { - // Mock getent to fail for all domains - mockExecaSync.mockImplementation(() => { - throw new Error('Resolution failed'); - }); + try { + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const copilotDir = path.join(fakeHome, '.copilot'); + expect(fs.existsSync(copilotDir)).toBe(false); - const config = { - ...getConfig(), - allowedDomains: ['unreachable.tailnet.example'], - }; - // Should not throw even if resolution fails - generateDockerCompose(config, mockNetworkConfig); - - // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); - expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; - expect(fs.existsSync(chrootHostsPath)).toBe(true); - const content = fs.readFileSync(chrootHostsPath, 'utf8'); - - // Should still have the base hosts content (localhost) - expect(content).toContain('localhost'); - // Should NOT contain the unresolvable domain - expect(content).not.toContain('unreachable.tailnet.example'); - - // Reset mock - mockExecaSync.mockReset(); - }); + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - it('should not add duplicate entries for domains already in /etc/hosts', () => { - // Mock getent to return a resolved IP - mockExecaSync.mockImplementation((...args: any[]) => { - if (args[0] === 'getent' && args[1]?.[0] === 'hosts') { - return { stdout: '127.0.0.1 localhost', stderr: '', exitCode: 0 }; - } - throw new Error('Not found'); + // Directory should NOT be auto-created (changed in #2114) + expect(fs.existsSync(copilotDir)).toBe(false); + // The blanket .copilot mount should be absent + expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); + // Optional self-hosted runner toolcache mount should also be absent + expect(volumes).not.toContain(`${fakeHome}/work/_tool:/host${fakeHome}/work/_tool:ro`); + // But session-state and logs overlays are always present + expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/session-state:rw`)); + expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/logs:rw`)); }); - - const config = { - ...getConfig(), - allowedDomains: ['localhost'], // localhost is already in /etc/hosts - }; - generateDockerCompose(config, mockNetworkConfig); - - // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) - const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); - expect(chrootDir).toBeDefined(); - const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; - const content = fs.readFileSync(chrootHostsPath, 'utf8'); - - // Count occurrences of 'localhost' - should only be the original entries, not duplicated - const localhostMatches = content.match(/localhost/g); - // /etc/hosts typically has multiple localhost entries (127.0.0.1 and ::1) - // The key assertion is that getent should NOT have been called for localhost - // since it's already in the hosts file - expect(localhostMatches).toBeDefined(); - - // Reset mock - mockExecaSync.mockReset(); - }); + } finally { + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('should use sessionStateDir when specified for chroot mounts', () => { + const configWithSessionDir = { ...getConfig(), sessionStateDir: '/custom/session-state' }; + const result = generateDockerCompose(configWithSessionDir, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + const homeDir = process.env.HOME || '/root'; + expect(volumes).toContain(`/custom/session-state:/host${homeDir}/.copilot/session-state:rw`); + expect(volumes).toContain(`/custom/session-state:${homeDir}/.copilot/session-state:rw`); + }); + + it('should mount /tmp under /host for chroot temp scripts', () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // /tmp:/host/tmp:rw is required for entrypoint.sh to write command scripts + expect(volumes).toContain('/tmp:/host/tmp:rw'); + }); + + it('should mount /etc/passwd and /etc/group for user lookup in chroot mode', () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // These are needed for getent/user lookup inside chroot + expect(volumes).toContain('/etc/passwd:/host/etc/passwd:ro'); + expect(volumes).toContain('/etc/group:/host/etc/group:ro'); + expect(volumes).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro'); + }); + + it('should mount read-only chroot-hosts when enableHostAccess is true', () => { + const config = { + ...getConfig(), + enableHostAccess: true + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Should mount a read-only copy of /etc/hosts with host.docker.internal pre-injected + const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); + expect(hostsVolume).toBeDefined(); + expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); + }); + + it('should inject host.docker.internal into chroot-hosts file', () => { + const config = { + ...getConfig(), + enableHostAccess: true + }; + generateDockerCompose(config, mockNetworkConfig); + + // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); + expect(chrootDir).toBeDefined(); + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; + expect(fs.existsSync(chrootHostsPath)).toBe(true); + const content = fs.readFileSync(chrootHostsPath, 'utf8'); + // Docker bridge gateway resolution may succeed or fail in test env, + // but the file should exist with at least localhost + expect(content).toContain('localhost'); + }); + + it('should mount custom chroot-hosts even without enableHostAccess', () => { + const config = { + ...getConfig(), + enableHostAccess: false + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + + // Should mount a custom hosts file in a secure chroot temp dir (for pre-resolved domains) + const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts')); + expect(hostsVolume).toBeDefined(); + expect(hostsVolume).toMatch(/chroot-.*\/hosts:\/host\/etc\/hosts:ro/); + }); + + it('should pre-resolve allowed domains into chroot-hosts file', () => { + // Mock getent to return a resolved IP for a test domain + mockExecaSync.mockImplementation((...args: any[]) => { + if (args[0] === 'getent' && args[1]?.[0] === 'hosts') { + const domain = args[1][1]; + if (domain === 'github.com') { + return { stdout: '140.82.121.4 github.com', stderr: '', exitCode: 0 }; + } + if (domain === 'npmjs.org') { + return { stdout: '104.16.22.35 npmjs.org', stderr: '', exitCode: 0 }; + } + throw new Error('Resolution failed'); + } + // For docker network inspect (host.docker.internal) + throw new Error('Not found'); + }); + + const config = { + ...getConfig(), + allowedDomains: ['github.com', 'npmjs.org', '*.wildcard.com'], + }; + generateDockerCompose(config, mockNetworkConfig); + + // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); + expect(chrootDir).toBeDefined(); + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; + expect(fs.existsSync(chrootHostsPath)).toBe(true); + const content = fs.readFileSync(chrootHostsPath, 'utf8'); + + // Should contain pre-resolved domains + expect(content).toContain('140.82.121.4\tgithub.com'); + expect(content).toContain('104.16.22.35\tnpmjs.org'); + // Should NOT contain wildcard domains (can't be resolved) + expect(content).not.toContain('wildcard.com'); + + // Reset mock + mockExecaSync.mockReset(); + }); + + it('should skip domains that fail to resolve during pre-resolution', () => { + // Mock getent to fail for all domains + mockExecaSync.mockImplementation(() => { + throw new Error('Resolution failed'); + }); + + const config = { + ...getConfig(), + allowedDomains: ['unreachable.tailnet.example'], + }; + // Should not throw even if resolution fails + generateDockerCompose(config, mockNetworkConfig); + + // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); + expect(chrootDir).toBeDefined(); + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; + expect(fs.existsSync(chrootHostsPath)).toBe(true); + const content = fs.readFileSync(chrootHostsPath, 'utf8'); + + // Should still have the base hosts content (localhost) + expect(content).toContain('localhost'); + // Should NOT contain the unresolvable domain + expect(content).not.toContain('unreachable.tailnet.example'); + + // Reset mock + mockExecaSync.mockReset(); + }); + + it('should not add duplicate entries for domains already in /etc/hosts', () => { + // Mock getent to return a resolved IP + mockExecaSync.mockImplementation((...args: any[]) => { + if (args[0] === 'getent' && args[1]?.[0] === 'hosts') { + return { stdout: '127.0.0.1 localhost', stderr: '', exitCode: 0 }; + } + throw new Error('Not found'); + }); + + const config = { + ...getConfig(), + allowedDomains: ['localhost'], // localhost is already in /etc/hosts + }; + generateDockerCompose(config, mockNetworkConfig); + + // Find the chroot hosts file (mkdtempSync creates chroot-XXXXXX directory) + const chrootDir = fs.readdirSync(getConfig().workDir).find(d => d.startsWith('chroot-')); + expect(chrootDir).toBeDefined(); + const chrootHostsPath = `${getConfig().workDir}/${chrootDir}/hosts`; + const content = fs.readFileSync(chrootHostsPath, 'utf8'); + + // Count occurrences of 'localhost' - should only be the original entries, not duplicated + const localhostMatches = content.match(/localhost/g); + // /etc/hosts typically has multiple localhost entries (127.0.0.1 and ::1) + // The key assertion is that getent should NOT have been called for localhost + // since it's already in the hosts file + expect(localhostMatches).toBeDefined(); + + // Reset mock + mockExecaSync.mockReset(); + }); }); diff --git a/src/services/service-test-setup.test-utils.ts b/src/services/service-test-setup.test-utils.ts index f57f55c51..72c34fa81 100644 --- a/src/services/service-test-setup.test-utils.ts +++ b/src/services/service-test-setup.test-utils.ts @@ -33,13 +33,20 @@ import { baseConfig, useTempWorkDir } from '../test-helpers/docker-test-fixtures * ``` */ export function useAgentVolumesTestConfig(): { getConfig: () => WrapperConfig } { - let mockConfig: WrapperConfig; + let mockConfig: WrapperConfig | undefined; + const getConfig = (): WrapperConfig => { + if (!mockConfig) { + throw new Error('Agent volumes test config is not initialized'); + } + return mockConfig; + }; + useTempWorkDir( baseConfig, (c) => { mockConfig = c; }, - () => mockConfig, + getConfig, ); - return { getConfig: () => mockConfig }; + return { getConfig }; }