Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/config-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jest.mock('fs', () => {
const actual = jest.requireActual<typeof import('fs')>('fs');
return {
...actual,
chmodSync: jest.fn((...args: Parameters<typeof actual.chmodSync>) =>
actual.chmodSync(...args)
),
chownSync: jest.fn(),
existsSync: jest.fn((...args: Parameters<typeof actual.existsSync>) =>
actual.existsSync(...args)
Expand Down Expand Up @@ -234,6 +237,41 @@ 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 });

await writeConfigs(buildWriteConfig());

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);
Expand Down
89 changes: 86 additions & 3 deletions src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
// `<emptyHome>/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.
// `<emptyHome>/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
Expand Down Expand Up @@ -169,20 +232,40 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
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 });
fs.chownSync(dirPath, uid, gid);
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)) {
createMissingOwnedDirectorySegments(config.runnerToolCachePath, uid, gid);
logger.debug(`Created runner tool cache directory: ${config.runnerToolCachePath} (${uid}:${gid})`);
}
Comment thread
Copilot marked this conversation as resolved.

// 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)
Expand Down
28 changes: 28 additions & 0 deletions src/runner-tool-cache.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 1 addition & 23 deletions src/services/agent-volumes/home-strategy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
28 changes: 28 additions & 0 deletions tests/integration/chroot-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
/// <reference path="../jest-custom-matchers.d.ts" />

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';
Expand Down Expand Up @@ -70,6 +73,31 @@ 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 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 });
}
Comment thread
Copilot marked this conversation as resolved.
}, 180000);
});
// Environment Variables
test('should preserve PATH including tool cache paths', () => {
const r = batch.get('echo_path');
Expand Down
Loading