diff --git a/src/config-writer.test.ts b/src/config-writer.test.ts index 29f6fd11a..426bacd86 100644 --- a/src/config-writer.test.ts +++ b/src/config-writer.test.ts @@ -14,6 +14,9 @@ jest.mock('fs', () => { const actual = jest.requireActual('fs'); return { ...actual, + chmodSync: jest.fn((...args: Parameters) => + actual.chmodSync(...args) + ), chownSync: jest.fn(), existsSync: jest.fn((...args: Parameters) => actual.existsSync(...args) @@ -234,6 +237,51 @@ describe('writeConfigs', () => { expect(fs.existsSync(geminiDir)).toBe(true); }); + it('creates configured runner tool cache directory segments with correct ownership', async () => { + const runnerToolCacheParent = path.join(tempDir, 'runner-work'); + const runnerToolCachePath = path.join(runnerToolCacheParent, '_tool'); + expect(fs.existsSync(runnerToolCachePath)).toBe(false); + + await writeConfigs( + buildWriteConfig({ + runnerToolCachePath, + }) + ); + + expect(fs.existsSync(runnerToolCachePath)).toBe(true); + expect(fs.statSync(runnerToolCachePath).isDirectory()).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(runnerToolCacheParent, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(runnerToolCacheParent, 0o755); + expect(fs.chownSync).toHaveBeenCalledWith(runnerToolCachePath, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(runnerToolCachePath, 0o755); + }); + + it('prepares chroot mountpoint for fallback runner tool cache under home', async () => { + const runnerToolCachePath = path.join(tempDir, 'work', '_tool'); + fs.mkdirSync(runnerToolCachePath, { recursive: true }); + + // Unset RUNNER_TOOL_CACHE so resolveRunnerToolCachePath falls through to the + // home-relative fallback (work/_tool). Restore after the test. + const savedRunnerToolCache = process.env.RUNNER_TOOL_CACHE; + delete process.env.RUNNER_TOOL_CACHE; + try { + await writeConfigs(buildWriteConfig()); + } finally { + if (savedRunnerToolCache !== undefined) { + process.env.RUNNER_TOOL_CACHE = savedRunnerToolCache; + } + } + + const chrootWorkDir = path.join(`${tempDir}-chroot-home`, 'work'); + const chrootToolCacheDir = path.join(chrootWorkDir, '_tool'); + expect(fs.existsSync(chrootToolCacheDir)).toBe(true); + expect(fs.statSync(chrootToolCacheDir).isDirectory()).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(chrootWorkDir, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(chrootWorkDir, 0o755); + expect(fs.chownSync).toHaveBeenCalledWith(chrootToolCacheDir, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(chrootToolCacheDir, 0o755); + }); + it('does not create .gemini directory when geminiApiKey is not provided', async () => { const homeDir = tempDir; (getRealUserHome as jest.Mock).mockReturnValue(homeDir); diff --git a/src/config-writer.ts b/src/config-writer.ts index e345ed1e6..ad5873ea8 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -8,6 +8,7 @@ import { generateSessionCa, initSslDb, parseUrlPatterns, isOpenSslAvailable } fr import { SslConfig, SQUID_PORT, getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator'; import { resolveLogPaths } from './log-paths'; +import { resolveRunnerToolCachePath } from './runner-tool-cache'; // When bundled with esbuild, this global is replaced at build time with the // JSON content of containers/agent/seccomp-profile.json. In normal (tsc) @@ -47,6 +48,68 @@ function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): return created; } +function assertRealDirectory(dirPath: string): void { + const lstat = fs.lstatSync(dirPath); + if (lstat.isSymbolicLink()) { + throw new Error(`Refusing to use symlink as directory: ${dirPath}`); + } + + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${dirPath}`); + } +} + +function createMissingOwnedDirectorySegments(dirPath: string, uid: number, gid: number): void { + let currentPath = path.isAbsolute(dirPath) + ? path.parse(dirPath).root + : ''; + const segments = dirPath.split(path.sep).filter(Boolean); + + for (const segment of segments) { + currentPath = currentPath ? path.join(currentPath, segment) : segment; + let created = false; + if (!fs.existsSync(currentPath)) { + fs.mkdirSync(currentPath); + created = true; + } + + assertRealDirectory(currentPath); + + if (created) { + fs.chownSync(currentPath, uid, gid); + fs.chmodSync(currentPath, 0o755); + } + } +} + +// Prepare a nested bind-mount destination inside the empty chroot home before +// Docker sees it. Without this, Docker may create intermediate parents such as +// `/work` as root-owned directories with restrictive traversal bits, +// causing the chrooted runner user to get EACCES before reaching the leaf mount. +// This operates only on the chroot-home placeholder path, e.g. +// `/work/_tool`; it does not chown or chmod the real host source +// `/home/runner/work/_tool`, which Docker will later mount over the placeholder. +function prepareChrootHomeMountpoint(emptyHomeDir: string, relativeMountPath: string, uid: number, gid: number): string { + let chrootPath = emptyHomeDir; + const segments = relativeMountPath.split(path.sep).filter(Boolean); + + for (const segment of segments) { + chrootPath = path.join(chrootPath, segment); + if (!fs.existsSync(chrootPath)) { + fs.mkdirSync(chrootPath); + } + + // The final segment may be the leaf mountpoint (`_tool`). That is okay: this + // is still only the placeholder inside emptyHomeDir, not the host tool cache. + assertRealDirectory(chrootPath); + fs.chownSync(chrootPath, uid, gid); + fs.chmodSync(chrootPath, 0o755); + } + + return chrootPath; +} + /** * Writes configuration files to disk * Uses fixed network configuration (172.30.0.0/24) defined in host-iptables.ts @@ -169,13 +232,13 @@ export async function writeConfigs(config: WrapperConfig): Promise { fs.chownSync(emptyHomeDir, uid, gid); logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); - // Ensure source directories for subdirectory mounts exist with correct ownership - const chrootHomeDirs = [ + // Ensure source directories for home subdirectory mounts exist with correct ownership. + const hostHomeMountSourceDirs = [ '.copilot', '.cache', '.config', '.local', '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', ...(config.geminiApiKey ? ['.gemini'] : []), ]; - for (const dir of chrootHomeDirs) { + for (const dir of hostHomeMountSourceDirs) { const dirPath = path.join(effectiveHome, dir); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); @@ -183,6 +246,33 @@ export async function writeConfigs(config: WrapperConfig): Promise { logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`); } } + + // Source-side prep: this only applies when the config file explicitly names + // a runner tool-cache source path that does not exist yet. Create that host + // source so Docker has something real to bind-mount later. + if (config.runnerToolCachePath && !fs.existsSync(config.runnerToolCachePath)) { + const relToHome = path.relative(effectiveHome, config.runnerToolCachePath); + const isUnderHome = relToHome && !relToHome.startsWith('..') && !path.isAbsolute(relToHome); + + if (isUnderHome) { + createMissingOwnedDirectorySegments(config.runnerToolCachePath, uid, gid); + logger.debug(`Created runner tool cache directory: ${config.runnerToolCachePath} (${uid}:${gid})`); + } else { + logger.warn(`Runner tool cache path does not exist; refusing to create outside effective home (${effectiveHome}): ${config.runnerToolCachePath}`); + } + } + + // Destination-side prep: resolve the same source path that home-strategy.ts + // will mount. If that source is nested under the empty chroot home, prepare + // the placeholder mountpoint there so Docker does not create parents as root. + const runnerToolCachePath = resolveRunnerToolCachePath(config, effectiveHome); + if (runnerToolCachePath) { + const relativeToolCachePath = path.relative(effectiveHome, runnerToolCachePath); + if (relativeToolCachePath && !relativeToolCachePath.startsWith('..') && !path.isAbsolute(relativeToolCachePath)) { + const chrootToolCachePath = prepareChrootHomeMountpoint(emptyHomeDir, relativeToolCachePath, uid, gid); + logger.debug(`Prepared chroot runner tool cache mountpoint: ${chrootToolCachePath} (${uid}:${gid})`); + } + } } // Use fixed network configuration (network is created by host-iptables.ts) diff --git a/src/dind-bootstrap.test.ts b/src/dind-bootstrap.test.ts index 696a85be0..b5361e30e 100644 --- a/src/dind-bootstrap.test.ts +++ b/src/dind-bootstrap.test.ts @@ -95,4 +95,126 @@ describe('runDindBootstrap', () => { expect(mockExecaFn).not.toHaveBeenCalled(); }); + + it('returns early when dind config has neither preStageDirs nor stageEngineBinary', async () => { + await runDindBootstrap(makeConfig({ dind: undefined })); + expect(mockExecaFn).not.toHaveBeenCalled(); + }); + + it('detects DinD via enableDind flag', async () => { + delete process.env.DOCKER_HOST; + await runDindBootstrap(makeConfig({ + enableDind: true, + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'busybox:latest', + }, + })); + + expect(mockExecaFn).toHaveBeenCalled(); + }); + + it('uses default staging image and workDir when not specified', async () => { + await runDindBootstrap(makeConfig({ + dind: { preStageDirs: true }, + })); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['-v', '/tmp/gh-aw:/awf-work:rw', 'ghcr.io/github/gh-aw-firewall/agent:latest']), + expect.any(Object), + ); + }); + + it('uses source path as targetPath when stageEngineBinary.targetPath is omitted', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-dind-bootstrap-')); + const sourcePath = path.join(tempDir, 'copilot'); + fs.writeFileSync(sourcePath, 'binary-data'); + + try { + await runDindBootstrap(makeConfig({ + dind: { + stageEngineBinary: { path: sourcePath }, + stagingImage: 'busybox:latest', + }, + })); + + const targetDir = path.posix.dirname(sourcePath); + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['-v', `${targetDir}:/awf-target:rw`]), + expect.any(Object), + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('detects DinD via unix socket path that is not a standard socket', async () => { + process.env.DOCKER_HOST = 'unix:///run/custom/docker.sock'; + await runDindBootstrap(makeConfig({ + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'busybox:latest', + }, + })); + + expect(mockExecaFn).toHaveBeenCalled(); + }); + + it('throws when preStageDirs workDir is a relative path', async () => { + await expect( + runDindBootstrap(makeConfig({ + dind: { + preStageDirs: true, + workDir: 'relative/path', + stagingImage: 'busybox:latest', + }, + })), + ).rejects.toThrow('dind.workDir must be an absolute path'); + }); + + it('throws when stageEngineBinary targetPath has an unsafe file name', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-dind-bootstrap-')); + const sourcePath = path.join(tempDir, 'copilot'); + fs.writeFileSync(sourcePath, 'binary-data'); + + try { + await expect( + runDindBootstrap(makeConfig({ + dind: { + stageEngineBinary: { + path: sourcePath, + targetPath: '/usr/local/bin/bad name!', + }, + stagingImage: 'busybox:latest', + }, + })), + ).rejects.toThrow('dind.stageEngineBinary.targetPath has unsafe file name'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('throws when stageEngineBinary source path is a directory, not a file', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-dind-bootstrap-')); + + try { + await expect( + runDindBootstrap(makeConfig({ + dind: { + stageEngineBinary: { + path: tempDir, + targetPath: '/usr/local/bin/copilot', + }, + stagingImage: 'busybox:latest', + }, + })), + ).rejects.toThrow('dind.stageEngineBinary.path is not a file'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/runner-tool-cache.ts b/src/runner-tool-cache.ts new file mode 100644 index 000000000..f1389ac71 --- /dev/null +++ b/src/runner-tool-cache.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { WrapperConfig } from './types'; + +function isDirectory(candidate: string): boolean { + try { + const stat = fs.lstatSync(candidate); + return stat.isDirectory(); + } catch { + return false; + } +} + +export function resolveRunnerToolCachePath(config: WrapperConfig, effectiveHome: string): string | undefined { + const candidates = [ + config.runnerToolCachePath, + process.env.RUNNER_TOOL_CACHE, + path.join(effectiveHome, 'work', '_tool'), + ]; + + for (const candidate of candidates) { + if (candidate && isDirectory(candidate)) { + return candidate; + } + } + + return undefined; +} \ No newline at end of file diff --git a/src/services/agent-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index d6e508374..ae8b3b9dd 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -604,6 +604,39 @@ describe('agent service', () => { } }); + it('should skip .copilot bind mount and warn when directory exists but is not accessible', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + + try { + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const copilotDir = path.join(fakeHome, '.copilot'); + fs.mkdirSync(copilotDir, { recursive: true }); + // Remove all permissions so accessSync throws + fs.chmodSync(copilotDir, 0o000); + + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + // The blanket .copilot mount should be skipped + expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); + // A warning should have been emitted + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Cannot access ~/.copilot directory')); + // 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 { + warnSpy.mockRestore(); + // Restore permissions so cleanup can proceed + fs.chmodSync(copilotDir, 0o755); + } + }); + } 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); diff --git a/src/services/agent-volumes/home-strategy.test.ts b/src/services/agent-volumes/home-strategy.test.ts new file mode 100644 index 000000000..669ef6832 --- /dev/null +++ b/src/services/agent-volumes/home-strategy.test.ts @@ -0,0 +1,93 @@ +import { buildHomeMounts } from './home-strategy'; +import { logger } from '../../logger'; + +// Mock fs so that accessSync and existsSync can be controlled per test. +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + accessSync: jest.fn((...args: Parameters) => + actual.accessSync(...args) + ), + existsSync: jest.fn((...args: Parameters) => + actual.existsSync(...args) + ), + }; +}); + +import * as fs from 'fs'; + +jest.mock('../../runner-tool-cache', () => ({ + resolveRunnerToolCachePath: jest.fn().mockReturnValue(undefined), +})); + +function makeParams(effectiveHome = '/home/runner'): Parameters[0] { + return { + config: { + workDir: '/tmp/awf-test', + agentCommand: 'echo test', + allowedDomains: [], + } as unknown as Parameters[0]['config'], + effectiveHome, + agentLogsPath: '/tmp/awf-test/agent-logs', + sessionStatePath: '/tmp/awf-test/agent-session-state', + }; +} + +/** Make fs.existsSync return true only for paths ending with `.copilot`. */ +function mockExistsForCopilot(): void { + (fs.existsSync as jest.Mock).mockImplementation((p: unknown) => { + if (typeof p === 'string' && p.endsWith('.copilot')) return true; + return false; + }); +} + +describe('buildHomeMounts', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('~/.copilot access error handling', () => { + it('includes error.message in warning when accessSync throws an Error instance', () => { + mockExistsForCopilot(); + (fs.accessSync as jest.Mock).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + buildHomeMounts(makeParams()); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('EACCES: permission denied') + ); + }); + + it('includes String(error) in warning when accessSync throws a non-Error value', () => { + mockExistsForCopilot(); + (fs.accessSync as jest.Mock).mockImplementation(() => { + throw 'non-error permission failure'; // NOLINT: intentionally throwing a non-Error to test the String(error) branch + }); + + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + buildHomeMounts(makeParams()); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('non-error permission failure') + ); + }); + + it('skips the .copilot bind mount when accessSync throws', () => { + mockExistsForCopilot(); + (fs.accessSync as jest.Mock).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + const mounts = buildHomeMounts(makeParams()); + + expect(mounts).not.toContain( + '/home/runner/.copilot:/host/home/runner/.copilot:rw' + ); + }); + }); +}); diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index a05e21bde..449facf92 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { logger } from '../../logger'; +import { resolveRunnerToolCachePath } from '../../runner-tool-cache'; import { WrapperConfig } from '../../types'; interface HomeMountsParams { @@ -64,26 +65,3 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { return mounts; } - -function resolveRunnerToolCachePath(config: WrapperConfig, effectiveHome: string): string | undefined { - const candidates = [ - config.runnerToolCachePath, - process.env.RUNNER_TOOL_CACHE, - path.join(effectiveHome, 'work', '_tool'), - ]; - - for (const candidate of candidates) { - if (!candidate) continue; - - try { - const stat = fs.lstatSync(candidate); - if (stat.isDirectory()) { - return candidate; - } - } catch { - // Path does not exist or is not accessible — try next candidate - } - } - - return undefined; -} diff --git a/tests/integration/chroot-edge-cases.test.ts b/tests/integration/chroot-edge-cases.test.ts index 709b67d6a..64778fb6e 100644 --- a/tests/integration/chroot-edge-cases.test.ts +++ b/tests/integration/chroot-edge-cases.test.ts @@ -14,6 +14,9 @@ /// import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; import { runBatch, BatchResults } from '../fixtures/batch-runner'; @@ -70,6 +73,35 @@ describe('Chroot Edge Cases', () => { }); }, 180000); + + describe('Runner Tool Cache Mounts', () => { + test('should access fallback runner tool cache nested under home', async () => { + const toolCacheDir = path.join(os.homedir(), 'work', '_tool'); + const toolCacheDirExisted = fs.existsSync(toolCacheDir); + const markerPath = path.join(toolCacheDir, `awf-toolcache-${Date.now()}.txt`); + fs.mkdirSync(toolCacheDir, { recursive: true }); + fs.writeFileSync(markerPath, 'toolcache-ok\n'); + + try { + const result = await runner.runWithSudo(`cat "${markerPath}"`, { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 120000, + env: { + RUNNER_TOOL_CACHE: '', + }, + }); + + expect(result).toSucceed(); + expect(result.stdout).toContain('toolcache-ok'); + } finally { + fs.rmSync(markerPath, { force: true }); + if (!toolCacheDirExisted) { + fs.rmSync(toolCacheDir, { recursive: true, force: true }); + } + } + }, 180000); + }); // Environment Variables test('should preserve PATH including tool cache paths', () => { const r = batch.get('echo_path');