diff --git a/src/chroot-home-setup.ts b/src/chroot-home-setup.ts new file mode 100644 index 000000000..711b3c6a3 --- /dev/null +++ b/src/chroot-home-setup.ts @@ -0,0 +1,110 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { WrapperConfig } from './types'; +import { logger } from './logger'; +import { getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; +import { assertRealDirectory, createMissingOwnedDirectorySegments } from './fs-utils'; +import { resolveRunnerToolCachePath } from './runner-tool-cache'; + +// 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. +export 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; +} + +/** + * Creates the empty chroot home directory placeholder, all whitelisted ~/. + * subdirectories on the host, and any runner tool-cache mountpoints so Docker + * does not create them as root-owned before bind mounts are established. + * + * Security note: this enforces correct UID/GID ownership on chroot home paths + * before Docker bind-mounts overwrite the placeholders at container start. + */ +export function prepareChrootHomeMounts(config: WrapperConfig): void { + // Ensure chroot home subdirectories exist with correct ownership before Docker + // bind-mounts them. If a source directory doesn't exist, Docker creates it as + // root:root, making it inaccessible to the agent user (e.g., UID 1001). + // Also create an empty writable home directory that gets mounted as $HOME + // in the chroot, giving tools a writable home without exposing credentials. + const effectiveHome = getRealUserHome(); + const uid = parseInt(getSafeHostUid(), 10); + const gid = parseInt(getSafeHostGid(), 10); + + // Create empty writable home directory for the chroot + // This is mounted as $HOME inside the container so tools can write to it + // NOTE: Must be outside workDir to avoid being hidden by the tmpfs overlay + const emptyHomeDir = `${config.workDir}-chroot-home`; + if (!fs.existsSync(emptyHomeDir)) { + fs.mkdirSync(emptyHomeDir, { recursive: true }); + } + fs.chownSync(emptyHomeDir, uid, gid); + logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); + + // 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 || config.googleApiKey ? ['.gemini'] : []), + ]; + 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})`); + } else if (dir === '.gemini') { + // Repair existing .gemini ownership for Gemini/Vertex runs where prior + // root-owned bind mounts can break atomic writes in the CLI. + fs.chownSync(dirPath, uid, gid); + logger.debug(`Fixed host home subdirectory ownership: ${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})`); + } + } +} diff --git a/src/fs-utils.ts b/src/fs-utils.ts new file mode 100644 index 000000000..847f1305b --- /dev/null +++ b/src/fs-utils.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface EnsureDirectoryOptions { + mode?: number; + onCreate?: () => void; + onExists?: () => void; + onAfterEnsure?: () => void; +} + +export function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): boolean { + const { mode, onCreate, onExists, onAfterEnsure } = options; + let created: boolean; + try { + created = Boolean( + fs.mkdirSync(dirPath, mode === undefined ? { recursive: true } : { recursive: true, mode }) + ); + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { + const uid = process.getuid?.() ?? '?'; + // Identify the blocking ancestor for actionable diagnostics. + // Walk up and choose the first existing ancestor that fails a W_OK|X_OK access + // check — that is the actual permission boundary. Fall back to the nearest + // existing ancestor if every ancestor passes (unusual, but avoids a null result). + let blocker: string | null = null; + let nearestExisting: string | null = null; + let current = path.resolve(dirPath); + while (current !== path.dirname(current)) { + if (fs.existsSync(current)) { + if (nearestExisting === null) { + nearestExisting = current; + } + try { + fs.accessSync(current, fs.constants.W_OK | fs.constants.X_OK); + } catch { + // This ancestor fails the access check — it is the real blocker. + blocker = current; + break; + } + } + current = path.dirname(current); + } + if (blocker === null) { + blocker = nearestExisting; + } + throw new Error( + `EACCES: cannot create directory ${dirPath} (running as uid=${uid}).\n` + + ` Blocked by: ${blocker ?? dirPath}\n` + + ` This is typically caused by a previous AWF run leaving root-owned directories.\n` + + ` The orchestrator must clean up stale directories before invoking AWF.` + ); + } + throw error; + } + + 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}`); + } + + if (created) { + onCreate?.(); + } else { + onExists?.(); + } + + onAfterEnsure?.(); + return created; +} + +export 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}`); + } +} + +export 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; + } + + // Validate the current segment is a directory. Allow root-owned system symlinks + // (e.g. /var on macOS) but refuse user-controlled symlinks. + const lstat = fs.lstatSync(currentPath); + if (lstat.isSymbolicLink() && (created || lstat.uid !== 0)) { + throw new Error(`Refusing to use symlink as directory: ${currentPath}`); + } + const stat = fs.statSync(currentPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${currentPath}`); + } + + if (created) { + fs.chownSync(currentPath, uid, gid); + fs.chmodSync(currentPath, 0o755); + } + } +} diff --git a/src/log-directory-setup.ts b/src/log-directory-setup.ts new file mode 100644 index 000000000..19df8540c --- /dev/null +++ b/src/log-directory-setup.ts @@ -0,0 +1,149 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; +import { getSafeHostUid, getSafeHostGid } from './host-env'; +import { LogPaths } from './log-paths'; +import { ensureDirectory } from './fs-utils'; + +export const MCP_LOGS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function pruneStaleMcpLogDirs(mcpLogsDir: string): void { + try { + const entries = fs.readdirSync(mcpLogsDir, { withFileTypes: true }); + const now = Date.now(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirPath = path.join(mcpLogsDir, entry.name); + try { + const stat = fs.statSync(dirPath); + if (now - stat.mtimeMs > MCP_LOGS_MAX_AGE_MS) { + fs.rmSync(dirPath, { recursive: true, force: true }); + logger.debug(`Pruned stale MCP log directory: ${dirPath}`); + } + } catch { + // Skip entries we can't stat or remove (owned by another user, etc.) + } + } + } catch { + // Best-effort: if we can't read the directory, skip pruning silently + } +} + +/** + * Creates all log and session-state directories required before container + * startup, setting ownership and permissions for the respective service users. + */ +export function prepareLogDirectories(logPaths: LogPaths): void { + // Create agent logs directory for persistence + // Chown to host user so Copilot CLI can write logs (AWF runs as root, agent runs as host user) + ensureDirectory(logPaths.agentLogs, { + onAfterEnsure: () => { + try { + fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); + } catch { /* ignore chown failures in non-root context */ } + }, + }); + logger.debug(`Agent logs directory created at: ${logPaths.agentLogs}`); + + // Create agent session-state directory for persistence (events.jsonl, session data) + // If sessionStateDir is specified, write directly there (timeout-safe, predictable path) + // Otherwise, use workDir/agent-session-state (will be moved to /tmp after cleanup) + // Chown to host user so Copilot CLI can create session subdirs and write events.jsonl + ensureDirectory(logPaths.sessionState, { + onAfterEnsure: () => { + try { + fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); + } catch { /* ignore chown failures in non-root context */ } + }, + }); + logger.debug(`Agent session-state directory created at: ${logPaths.sessionState}`); + + // Create squid logs directory for persistence + // If proxyLogsDir is specified, write directly there (timeout-safe) + // Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup) + // + // TRIPLE-LAYER DEFENSE for squid log permissions: + // Layer 1 (here): best-effort chown to UID 13:13 on the host filesystem during workdir setup. + // On non-ARC deployments this is typically sufficient. + // On ARC/DinD this may be a no-op (daemon has a different filesystem view). + // Layer 2 (squid-service.ts entrypoint): chown preflight inside the container. + // Repairs ownership when Docker daemon auto-creates the bind-mount source + // as root:root on split filesystems. Required for ARC/DinD. + // Layer 3 (container-stop.ts): chmod -R a+rX before compose down. + // Ensures the runner user can read log files (owned by UID 13) after + // the container is removed, for `awf logs summary` and artifact uploads. + // + // Each layer compensates for the others' failure modes. Do not remove any + // layer without understanding all deployment topologies (shared FS, DinD, + // rootless Docker, NFS root-squash). + const SQUID_PROXY_UID = 13; + const SQUID_PROXY_GID = 13; + ensureDirectory(logPaths.squidLogs, { + mode: 0o755, + onAfterEnsure: () => { + try { + fs.chownSync(logPaths.squidLogs, SQUID_PROXY_UID, SQUID_PROXY_GID); + } catch { + // Fallback to world-writable if chown fails (e.g., non-root context, + // pre-existing dir owned by another user, NFS root-squash) + try { + fs.chmodSync(logPaths.squidLogs, 0o777); + } catch { /* best-effort — container entrypoint preflight will retry */ } + } + }, + }); + logger.debug(`Squid logs directory created at: ${logPaths.squidLogs}`); + + // Create api-proxy logs directory for persistence + // If proxyLogsDir is specified, write inside it as a subdirectory (timeout-safe, + // and included in the firewall-audit-logs artifact upload automatically) + // Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup) + // Note: API proxy runs as user 'apiproxy' (non-root) + ensureDirectory(logPaths.apiProxyLogs, { + mode: 0o777, + onCreate: () => { + // Explicitly set permissions to 0o777 (not affected by umask) + fs.chmodSync(logPaths.apiProxyLogs, 0o777); + }, + }); + logger.debug(`API proxy logs directory created at: ${logPaths.apiProxyLogs}`); + + // Create CLI proxy logs directory for persistence + // Note: CLI proxy runs as user 'cliproxy' (non-root) + ensureDirectory(logPaths.cliProxyLogs, { + mode: 0o777, + onCreate: () => fs.chmodSync(logPaths.cliProxyLogs, 0o777), + }); + logger.debug(`CLI proxy logs directory created at: ${logPaths.cliProxyLogs}`); + + // Create /tmp/gh-aw/mcp-logs directory + // This directory exists on the HOST for MCP gateway to write logs + // Inside the AWF container, it's hidden via tmpfs mount (see generateDockerCompose) + // Uses mode 0o777 to allow GitHub Actions workflows and MCP gateway to create subdirectories + // even when AWF runs as root (e.g., sudo awf) + const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; + if (ensureDirectory(mcpLogsDir, { mode: 0o777 })) { + // Explicitly set permissions to 0o777 (not affected by umask) + fs.chmodSync(mcpLogsDir, 0o777); + logger.debug(`MCP logs directory created at: ${mcpLogsDir}`); + } else { + // Best-effort permission fix if directory already exists (e.g., created by MCP gateway + // or a previous run). May fail with EPERM if owned by a different user. + try { + fs.chmodSync(mcpLogsDir, 0o777); + logger.debug(`MCP logs directory permissions fixed at: ${mcpLogsDir}`); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EPERM' && code !== 'EROFS') { + throw error; + } + logger.debug(`MCP logs directory already exists at: ${mcpLogsDir} (chmod skipped: ${code})`); + } + } + + // Prune stale MCP log subdirectories to prevent unbounded growth on persistent + // runners. Each AWF run or MCP gateway session creates timestamped subdirs; + // without pruning these accumulate indefinitely since mcpLogsDir lives outside + // workDir and is not cleaned up by removeWorkDirectories(). + pruneStaleMcpLogDirs(mcpLogsDir); +} diff --git a/src/workdir-setup.test.ts b/src/workdir-setup.test.ts index f90ca2744..ab7421848 100644 --- a/src/workdir-setup.test.ts +++ b/src/workdir-setup.test.ts @@ -524,7 +524,7 @@ describe('ensureDirectory EACCES diagnostic', () => { ); }); - it('uses nearest existing ancestor as blocker even when it appears writable', () => { + it('falls back to nearest existing ancestor when no ancestor fails the access check', () => { const parentDir = path.join(fixture.tempDir, 'existing-parent'); const targetDir = path.join(parentDir, 'new-child'); actualFs.mkdirSync(parentDir, { recursive: true }); @@ -534,13 +534,44 @@ describe('ensureDirectory EACCES diagnostic', () => { { code: 'EACCES' } ); (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); - // accessSync succeeds (writable), but nearest ancestor is still reported as blocker + // accessSync delegates to actualFs (all real dirs are writable), so no ancestor + // fails the check and the code falls back to the nearest existing ancestor. expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow( new RegExp(`Blocked by: .*existing-parent`) ); }); + it('reports the first ancestor that fails the access check, not the nearest existing ancestor', () => { + const grandparentDir = path.join(fixture.tempDir, 'grandparent'); + const parentDir = path.join(grandparentDir, 'parent'); + const targetDir = path.join(parentDir, 'new-child'); + actualFs.mkdirSync(parentDir, { recursive: true }); + + const eacces = Object.assign( + new Error(`EACCES: permission denied, mkdir '${targetDir}'`), + { code: 'EACCES' } + ); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + // parentDir exists and passes the access check; grandparentDir exists but fails it. + (fs.accessSync as jest.Mock).mockImplementation((p: string, mode: number) => { + if (p === grandparentDir) { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); + } + return actualFs.accessSync(p, mode); + }); + + try { + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow( + new RegExp(`Blocked by: .*grandparent`) + ); + } finally { + (fs.accessSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.accessSync(...args) + ); + } + }); + it('falls back to dirPath in Blocked by when no existing ancestor is found', () => { const targetDir = path.join(fixture.tempDir, 'deep', 'nonexistent', 'dir'); diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts index 640a5a884..21bf45d41 100644 --- a/src/workdir-setup.ts +++ b/src/workdir-setup.ts @@ -1,360 +1,13 @@ -import * as fs from 'fs'; import * as path from 'path'; import { WrapperConfig } from './types'; -import { logger } from './logger'; -import { getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; import { LogPaths } from './log-paths'; -import { resolveRunnerToolCachePath } from './runner-tool-cache'; - -interface EnsureDirectoryOptions { - mode?: number; - onCreate?: () => void; - onExists?: () => void; - onAfterEnsure?: () => void; -} - -function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): boolean { - const { mode, onCreate, onExists, onAfterEnsure } = options; - let created: boolean; - try { - created = Boolean( - fs.mkdirSync(dirPath, mode === undefined ? { recursive: true } : { recursive: true, mode }) - ); - } catch (error: unknown) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { - const uid = process.getuid?.() ?? '?'; - // Identify the blocking ancestor for actionable diagnostics. - // Default to the nearest existing ancestor (more actionable than a path that - // does not exist), and confirm with a W_OK|X_OK access check. - let blocker: string | null = null; - let current = path.resolve(dirPath); - while (current !== path.dirname(current)) { - if (fs.existsSync(current)) { - blocker = current; - try { fs.accessSync(current, fs.constants.W_OK | fs.constants.X_OK); } catch { /* confirmed blocker */ } - break; - } - current = path.dirname(current); - } - throw new Error( - `EACCES: cannot create directory ${dirPath} (running as uid=${uid}).\n` + - ` Blocked by: ${blocker ?? dirPath}\n` + - ` This is typically caused by a previous AWF run leaving root-owned directories.\n` + - ` The orchestrator must clean up stale directories before invoking AWF.` - ); - } - throw error; - } - - 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}`); - } - - if (created) { - onCreate?.(); - } else { - onExists?.(); - } - - onAfterEnsure?.(); - 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; - } - - // Validate the current segment is a directory. Allow root-owned system symlinks - // (e.g. /var on macOS) but refuse user-controlled symlinks. - const lstat = fs.lstatSync(currentPath); - if (lstat.isSymbolicLink() && (created || lstat.uid !== 0)) { - throw new Error(`Refusing to use symlink as directory: ${currentPath}`); - } - const stat = fs.statSync(currentPath); - if (!stat.isDirectory()) { - throw new Error(`Expected directory but found non-directory path: ${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; -} - -/** - * Creates all log and session-state directories required before container - * startup, setting ownership and permissions for the respective service users. - */ -function prepareLogDirectories(logPaths: LogPaths): void { - // Create agent logs directory for persistence - // Chown to host user so Copilot CLI can write logs (AWF runs as root, agent runs as host user) - ensureDirectory(logPaths.agentLogs, { - onAfterEnsure: () => { - try { - fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); - } catch { /* ignore chown failures in non-root context */ } - }, - }); - logger.debug(`Agent logs directory created at: ${logPaths.agentLogs}`); - - // Create agent session-state directory for persistence (events.jsonl, session data) - // If sessionStateDir is specified, write directly there (timeout-safe, predictable path) - // Otherwise, use workDir/agent-session-state (will be moved to /tmp after cleanup) - // Chown to host user so Copilot CLI can create session subdirs and write events.jsonl - ensureDirectory(logPaths.sessionState, { - onAfterEnsure: () => { - try { - fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); - } catch { /* ignore chown failures in non-root context */ } - }, - }); - logger.debug(`Agent session-state directory created at: ${logPaths.sessionState}`); - - // Create squid logs directory for persistence - // If proxyLogsDir is specified, write directly there (timeout-safe) - // Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup) - // - // TRIPLE-LAYER DEFENSE for squid log permissions: - // Layer 1 (here): best-effort chown to UID 13:13 on the host filesystem during workdir setup. - // On non-ARC deployments this is typically sufficient. - // On ARC/DinD this may be a no-op (daemon has a different filesystem view). - // Layer 2 (squid-service.ts entrypoint): chown preflight inside the container. - // Repairs ownership when Docker daemon auto-creates the bind-mount source - // as root:root on split filesystems. Required for ARC/DinD. - // Layer 3 (container-stop.ts): chmod -R a+rX before compose down. - // Ensures the runner user can read log files (owned by UID 13) after - // the container is removed, for `awf logs summary` and artifact uploads. - // - // Each layer compensates for the others' failure modes. Do not remove any - // layer without understanding all deployment topologies (shared FS, DinD, - // rootless Docker, NFS root-squash). - const SQUID_PROXY_UID = 13; - const SQUID_PROXY_GID = 13; - ensureDirectory(logPaths.squidLogs, { - mode: 0o755, - onAfterEnsure: () => { - try { - fs.chownSync(logPaths.squidLogs, SQUID_PROXY_UID, SQUID_PROXY_GID); - } catch { - // Fallback to world-writable if chown fails (e.g., non-root context, - // pre-existing dir owned by another user, NFS root-squash) - try { - fs.chmodSync(logPaths.squidLogs, 0o777); - } catch { /* best-effort — container entrypoint preflight will retry */ } - } - }, - }); - logger.debug(`Squid logs directory created at: ${logPaths.squidLogs}`); - - // Create api-proxy logs directory for persistence - // If proxyLogsDir is specified, write inside it as a subdirectory (timeout-safe, - // and included in the firewall-audit-logs artifact upload automatically) - // Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup) - // Note: API proxy runs as user 'apiproxy' (non-root) - ensureDirectory(logPaths.apiProxyLogs, { - mode: 0o777, - onCreate: () => { - // Explicitly set permissions to 0o777 (not affected by umask) - fs.chmodSync(logPaths.apiProxyLogs, 0o777); - }, - }); - logger.debug(`API proxy logs directory created at: ${logPaths.apiProxyLogs}`); - - // Create CLI proxy logs directory for persistence - // Note: CLI proxy runs as user 'cliproxy' (non-root) - ensureDirectory(logPaths.cliProxyLogs, { - mode: 0o777, - onCreate: () => fs.chmodSync(logPaths.cliProxyLogs, 0o777), - }); - logger.debug(`CLI proxy logs directory created at: ${logPaths.cliProxyLogs}`); - - // Create /tmp/gh-aw/mcp-logs directory - // This directory exists on the HOST for MCP gateway to write logs - // Inside the AWF container, it's hidden via tmpfs mount (see generateDockerCompose) - // Uses mode 0o777 to allow GitHub Actions workflows and MCP gateway to create subdirectories - // even when AWF runs as root (e.g., sudo awf) - const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; - if (ensureDirectory(mcpLogsDir, { mode: 0o777 })) { - // Explicitly set permissions to 0o777 (not affected by umask) - fs.chmodSync(mcpLogsDir, 0o777); - logger.debug(`MCP logs directory created at: ${mcpLogsDir}`); - } else { - // Best-effort permission fix if directory already exists (e.g., created by MCP gateway - // or a previous run). May fail with EPERM if owned by a different user. - try { - fs.chmodSync(mcpLogsDir, 0o777); - logger.debug(`MCP logs directory permissions fixed at: ${mcpLogsDir}`); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EPERM' && code !== 'EROFS') { - throw error; - } - logger.debug(`MCP logs directory already exists at: ${mcpLogsDir} (chmod skipped: ${code})`); - } - } - - // Prune stale MCP log subdirectories to prevent unbounded growth on persistent - // runners. Each AWF run or MCP gateway session creates timestamped subdirs; - // without pruning these accumulate indefinitely since mcpLogsDir lives outside - // workDir and is not cleaned up by removeWorkDirectories(). - pruneStaleMcpLogDirs(mcpLogsDir); -} - -const MCP_LOGS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - -function pruneStaleMcpLogDirs(mcpLogsDir: string): void { - try { - const entries = fs.readdirSync(mcpLogsDir, { withFileTypes: true }); - const now = Date.now(); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const dirPath = path.join(mcpLogsDir, entry.name); - try { - const stat = fs.statSync(dirPath); - if (now - stat.mtimeMs > MCP_LOGS_MAX_AGE_MS) { - fs.rmSync(dirPath, { recursive: true, force: true }); - logger.debug(`Pruned stale MCP log directory: ${dirPath}`); - } - } catch { - // Skip entries we can't stat or remove (owned by another user, etc.) - } - } - } catch { - // Best-effort: if we can't read the directory, skip pruning silently - } -} - -/** - * Creates the empty chroot home directory placeholder, all whitelisted ~/. - * subdirectories on the host, and any runner tool-cache mountpoints so Docker - * does not create them as root-owned before bind mounts are established. - * - * Security note: this enforces correct UID/GID ownership on chroot home paths - * before Docker bind-mounts overwrite the placeholders at container start. - */ -function prepareChrootHomeMounts(config: WrapperConfig): void { - // Ensure chroot home subdirectories exist with correct ownership before Docker - // bind-mounts them. If a source directory doesn't exist, Docker creates it as - // root:root, making it inaccessible to the agent user (e.g., UID 1001). - // Also create an empty writable home directory that gets mounted as $HOME - // in the chroot, giving tools a writable home without exposing credentials. - const effectiveHome = getRealUserHome(); - const uid = parseInt(getSafeHostUid(), 10); - const gid = parseInt(getSafeHostGid(), 10); - - // Create empty writable home directory for the chroot - // This is mounted as $HOME inside the container so tools can write to it - // NOTE: Must be outside workDir to avoid being hidden by the tmpfs overlay - const emptyHomeDir = `${config.workDir}-chroot-home`; - if (!fs.existsSync(emptyHomeDir)) { - fs.mkdirSync(emptyHomeDir, { recursive: true }); - } - fs.chownSync(emptyHomeDir, uid, gid); - logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); - - // 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 || config.googleApiKey ? ['.gemini'] : []), - ]; - 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})`); - } else if (dir === '.gemini') { - // Repair existing .gemini ownership for Gemini/Vertex runs where prior - // root-owned bind mounts can break atomic writes in the CLI. - fs.chownSync(dirPath, uid, gid); - logger.debug(`Fixed host home subdirectory ownership: ${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})`); - } - } -} +import { + ensureDirectory, + assertRealDirectory, + createMissingOwnedDirectorySegments, +} from './fs-utils'; +import { prepareLogDirectories, pruneStaleMcpLogDirs, MCP_LOGS_MAX_AGE_MS } from './log-directory-setup'; +import { prepareChrootHomeMountpoint, prepareChrootHomeMounts } from './chroot-home-setup'; /** * Creates the init-signal directory used for iptables-init ↔ agent handshake.