Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
48 changes: 48 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,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);
Expand Down
96 changes: 93 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,47 @@ 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)) {
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}`);
}
}
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
122 changes: 122 additions & 0 deletions src/dind-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
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;
}
33 changes: 33 additions & 0 deletions src/services/agent-volumes-mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading