diff --git a/containers/api-proxy/server.websocket.test.js b/containers/api-proxy/server.websocket.test.js index ad3ef1165..ed3434a77 100644 --- a/containers/api-proxy/server.websocket.test.js +++ b/containers/api-proxy/server.websocket.test.js @@ -226,17 +226,19 @@ describe('proxyWebSocket', () => { wsProxy(req, socket, Buffer.alloc(0), 'api.openai.com', { 'Authorization': 'Bearer injected' }, 'openai'); - return new Promise(resolve => setTimeout(() => { - const upgradeWrite = tlsSocket.write.mock.calls.find( - c => typeof c[0] === 'string' && c[0].startsWith('GET ') - ); - expect(upgradeWrite).toBeDefined(); - const upgradeReqStr = upgradeWrite[0]; - expect(upgradeReqStr).not.toContain('client-supplied'); - expect(upgradeReqStr).not.toContain('client-api-key'); - expect(upgradeReqStr).toContain('Bearer injected'); - resolve(); - }, 30)); + return new Promise(resolve => { + tlsSocket.once('secureConnect', () => setImmediate(() => { + const upgradeWrite = tlsSocket.write.mock.calls.find( + c => typeof c[0] === 'string' && c[0].startsWith('GET ') + ); + expect(upgradeWrite).toBeDefined(); + const upgradeReqStr = upgradeWrite[0]; + expect(upgradeReqStr).not.toContain('client-supplied'); + expect(upgradeReqStr).not.toContain('client-api-key'); + expect(upgradeReqStr).toContain('Bearer injected'); + resolve(); + })); + }); }); it('forwards the CONNECT request to the configured Squid proxy host/port', () => { @@ -267,12 +269,14 @@ describe('proxyWebSocket', () => { const headBytes = Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]); // WS text frame: FIN=1, opcode=1, len=5, payload='Hello' wsProxy(makeUpgradeReq(), socket, headBytes, 'api.openai.com', { 'Authorization': 'Bearer k' }, 'openai'); - return new Promise(resolve => setTimeout(() => { - const bufWrite = tlsSocket.write.mock.calls.find(c => Buffer.isBuffer(c[0])); - expect(bufWrite).toBeDefined(); - expect(bufWrite[0]).toEqual(headBytes); - resolve(); - }, 30)); + return new Promise(resolve => { + tlsSocket.once('secureConnect', () => setImmediate(() => { + const bufWrite = tlsSocket.write.mock.calls.find(c => Buffer.isBuffer(c[0])); + expect(bufWrite).toBeDefined(); + expect(bufWrite[0]).toEqual(headBytes); + resolve(); + })); + }); }); }); }); diff --git a/src/config-writer-branches.test.ts b/src/config-writer-branches.test.ts index 50be5934d..b43e3ca5d 100644 --- a/src/config-writer-branches.test.ts +++ b/src/config-writer-branches.test.ts @@ -3,13 +3,14 @@ * - writeAuditArtifacts: auditDir symlink guard * - copySeccompProfile: alternate seccomp-profile.json fallback path * - initializeSslBump: non-Error rejection propagation + * - validateAndPrepareWorkDir: EACCES diagnostic */ import './test-helpers/config-writer-dependency-mocks.test-utils'; import * as fs from 'fs'; import * as path from 'path'; -import { writeConfigs } from './config-writer'; +import { writeConfigs, configWriterTestHelpers } from './config-writer'; import { isOpenSslAvailable, generateSessionCa, initSslDb } from './ssl-bump'; import { buildWriteConfig, @@ -125,4 +126,107 @@ describe('config-writer additional branches', () => { } }); }); + + // ─── validateAndPrepareWorkDir EACCES diagnostic ────────────────────────── + + describe('validateAndPrepareWorkDir EACCES diagnostic', () => { + it('throws with diagnostic naming the blocking ancestor in the suggested fix', () => { + const blockerDir = path.join(tempDir, 'stale-parent'); + const workDir = path.join(blockerDir, 'new-workdir'); + + const eacces = Object.assign( + new Error(`EACCES: permission denied, mkdir '${workDir}'`), + { code: 'EACCES' } + ); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + // existsSync: only stale-parent exists; workDir does not + (fs.existsSync as jest.Mock).mockImplementation((p: fs.PathLike) => + String(p) === blockerDir + ); + // statSync: blockerDir is root-owned + (fs.statSync as jest.Mock).mockReturnValue({ uid: 0, gid: 0, mode: 0o40755 } as fs.Stats); + // accessSync: blockerDir is not writable + (fs.accessSync as jest.Mock).mockImplementation(() => { throw new Error('EACCES'); }); + + try { + expect(() => + configWriterTestHelpers.validateAndPrepareWorkDir(buildWriteConfig(tempDir, { workDir })) + ).toThrow(/EACCES: cannot create work directory/); + + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + expect(() => + configWriterTestHelpers.validateAndPrepareWorkDir(buildWriteConfig(tempDir, { workDir })) + ).toThrow(new RegExp(`Suggested fix: sudo rm -rf ${blockerDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + } finally { + (fs.existsSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').existsSync(...args) + ); + (fs.statSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').statSync(...args) + ); + (fs.accessSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').accessSync(...args) + ); + } + }); + + it('falls back to workDir in the suggested fix when no blocking ancestor is identified', () => { + const workDir = path.join(tempDir, 'new-workdir'); + + const eacces = Object.assign( + new Error(`EACCES: permission denied, mkdir '${workDir}'`), + { code: 'EACCES' } + ); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + (fs.existsSync as jest.Mock).mockImplementation(() => false); + + try { + expect(() => + configWriterTestHelpers.validateAndPrepareWorkDir(buildWriteConfig(tempDir, { workDir })) + ).toThrow(new RegExp(`Suggested fix: sudo rm -rf ${workDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + } finally { + (fs.existsSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').existsSync(...args) + ); + } + }); + + it('uses W_OK | X_OK when checking writability of the ancestor', () => { + const blockerDir = path.join(tempDir, 'stale-parent'); + const workDir = path.join(blockerDir, 'new-workdir'); + + const eacces = Object.assign( + new Error(`EACCES: permission denied, mkdir '${workDir}'`), + { code: 'EACCES' } + ); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + (fs.existsSync as jest.Mock).mockImplementation((p: fs.PathLike) => + String(p) === blockerDir + ); + (fs.statSync as jest.Mock).mockReturnValue({ uid: 0, gid: 0, mode: 0o40755 } as fs.Stats); + (fs.accessSync as jest.Mock).mockImplementation(() => { throw new Error('EACCES'); }); + + const actualFsConstants = jest.requireActual('fs').constants; + + try { + expect(() => + configWriterTestHelpers.validateAndPrepareWorkDir(buildWriteConfig(tempDir, { workDir })) + ).toThrow(/EACCES/); + expect(fs.accessSync as jest.Mock).toHaveBeenCalledWith( + blockerDir, + actualFsConstants.W_OK | actualFsConstants.X_OK + ); + } finally { + (fs.existsSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').existsSync(...args) + ); + (fs.statSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').statSync(...args) + ); + (fs.accessSync as jest.Mock).mockImplementation( + (...args: Parameters) => jest.requireActual('fs').accessSync(...args) + ); + } + }); + }); }); diff --git a/src/config-writer.ts b/src/config-writer.ts index 8f8851309..f19878a0f 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -24,6 +24,54 @@ import { prepareWorkDirectories } from './workdir-setup'; // builds the identifier remains undeclared, so the typeof check below is safe. declare const __AWF_SECCOMP_PROFILE__: string | undefined; +/** + * Produces a human-readable diagnostic string explaining why EACCES occurred. + * Walks the path hierarchy to identify which ancestor is not writable/searchable. + * Returns the diagnostic string and the identified blocking path (if found). + */ +function diagnoseEacces(targetDir: string): { diagnosis: string; blockerPath: string | null } { + const resolvedTarget = path.resolve(targetDir); + let current = resolvedTarget; + const lines: string[] = []; + let blockerPath: string | null = null; + + // Walk up to find the blocking directory + while (current !== path.dirname(current)) { + if (fs.existsSync(current)) { + try { + const stat = fs.statSync(current); + const writable = isWritable(current); + lines.push( + ` ${current}: uid=${stat.uid} gid=${stat.gid} mode=${(stat.mode & 0o7777).toString(8)} writable=${writable}` + ); + if (!writable) { + blockerPath = current; + lines.push(` └─ BLOCKED HERE: current process (uid=${process.getuid?.() ?? '?'}) cannot write to this directory`); + break; + } + } catch { + lines.push(` ${current}: (cannot stat)`); + break; + } + } + current = path.dirname(current); + } + + const diagnosis = lines.length > 0 + ? `Path diagnosis:\n${lines.join('\n')}` + : `Path diagnosis: could not determine blocking ancestor`; + return { diagnosis, blockerPath }; +} + +function isWritable(dirPath: string): boolean { + try { + fs.accessSync(dirPath, fs.constants.W_OK | fs.constants.X_OK); + return true; + } catch { + return false; + } +} + /** Resolved network topology passed between setup phases. */ interface NetworkConfig { subnet: string; @@ -46,19 +94,35 @@ function validateAndPrepareWorkDir(config: WrapperConfig): void { // Ensure work directory exists with restricted permissions (owner-only access) // Defense-in-depth: even if tmpfs overlay fails, non-root processes on the host // cannot read the docker-compose.yml which contains sensitive tokens - const workDirCreated = Boolean( - fs.mkdirSync(config.workDir, { recursive: true, mode: 0o700 }) - ); - const workDirLstat = fs.lstatSync(config.workDir); - if (workDirLstat.isSymbolicLink()) { - throw new Error(`Refusing to use symlink as directory: ${config.workDir}`); - } - const workDirStat = fs.statSync(config.workDir); - if (!workDirStat.isDirectory()) { - throw new Error(`Expected directory but found non-directory path: ${config.workDir}`); - } - if (!workDirCreated) { - fs.chmodSync(config.workDir, 0o700); + try { + const workDirCreated = Boolean( + fs.mkdirSync(config.workDir, { recursive: true, mode: 0o700 }) + ); + const workDirLstat = fs.lstatSync(config.workDir); + if (workDirLstat.isSymbolicLink()) { + throw new Error(`Refusing to use symlink as directory: ${config.workDir}`); + } + const workDirStat = fs.statSync(config.workDir); + if (!workDirStat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${config.workDir}`); + } + if (!workDirCreated) { + fs.chmodSync(config.workDir, 0o700); + } + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { + const { diagnosis, blockerPath } = diagnoseEacces(config.workDir); + const suggestedPath = blockerPath ?? config.workDir; + throw new Error( + `EACCES: cannot create work directory: ${config.workDir}\n` + + `${diagnosis}\n` + + `This typically happens on persistent runners when a previous AWF run ` + + `left directories owned by root. The calling process (e.g., gh-aw setup) ` + + `must remove or chown the stale directory before invoking AWF.\n` + + ` Suggested fix: sudo rm -rf ${suggestedPath} && mkdir -p ${suggestedPath}` + ); + } + throw error; } } diff --git a/src/test-helpers/config-writer-test-harness.test-utils.ts b/src/test-helpers/config-writer-test-harness.test-utils.ts index 39450940f..045df8fbf 100644 --- a/src/test-helpers/config-writer-test-harness.test-utils.ts +++ b/src/test-helpers/config-writer-test-harness.test-utils.ts @@ -82,8 +82,19 @@ export function buildWriteConfig( */ export function setupConfigWriterTempDir(prefix = 'config-writer-test-'): string { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const actualFs = jest.requireActual('fs'); jest.clearAllMocks(); (fs.chownSync as unknown as jest.Mock).mockImplementation(() => undefined); + // Restore passthrough impls for mocks added to fsMockFactory that may be overridden per-test + (fs.mkdirSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.mkdirSync(...args), + ); + (fs.accessSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.accessSync(...args), + ); + (fs.statSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.statSync(...args), + ); (getRealUserHome as jest.Mock).mockReturnValue(tempDir); return tempDir; } diff --git a/src/test-helpers/fs-mock-factory.test-utils.ts b/src/test-helpers/fs-mock-factory.test-utils.ts index b448c9759..e5fe6c14c 100644 --- a/src/test-helpers/fs-mock-factory.test-utils.ts +++ b/src/test-helpers/fs-mock-factory.test-utils.ts @@ -4,6 +4,9 @@ export function fsMockFactory() { const actual = jest.requireActual('fs'); return { ...actual, + mkdirSync: jest.fn((...args: Parameters) => actual.mkdirSync(...args)) as typeof actual.mkdirSync, + accessSync: jest.fn((...args: Parameters) => actual.accessSync(...args)) as typeof actual.accessSync, + statSync: jest.fn((...args: Parameters) => actual.statSync(...args)) as typeof actual.statSync, chmodSync: jest.fn((...args: Parameters) => actual.chmodSync(...args)), chownSync: jest.fn(), existsSync: jest.fn((...args: Parameters) => actual.existsSync(...args)), diff --git a/src/workdir-setup.test.ts b/src/workdir-setup.test.ts index 215c2b8fd..f90ca2744 100644 --- a/src/workdir-setup.test.ts +++ b/src/workdir-setup.test.ts @@ -41,6 +41,12 @@ function setupWorkdirFixture({ cleanupChrootHome = true } = {}) { (fs.chmodSync as jest.Mock).mockImplementation( (...args: Parameters) => actualFs.chmodSync(...args), ); + (fs.mkdirSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.mkdirSync(...args), + ); + (fs.accessSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.accessSync(...args), + ); (getRealUserHome as jest.Mock).mockReturnValue(tempDir); }); @@ -497,3 +503,88 @@ describe('prepareChrootHomeMounts (sub-function)', () => { expect(fs.existsSync(geminiDir)).toBe(false); }); }); + +describe('ensureDirectory EACCES diagnostic', () => { + const fixture = setupWorkdirFixture({ cleanupChrootHome: false }); + + it('throws a diagnostic error naming the nearest existing ancestor as blocker', () => { + const parentDir = path.join(fixture.tempDir, 'stale-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; }); + (fs.accessSync as jest.Mock).mockImplementationOnce(() => { throw new Error('EACCES'); }); + + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow( + new RegExp(`Blocked by: .*stale-parent`) + ); + }); + + it('uses nearest existing ancestor as blocker even when it appears writable', () => { + const parentDir = path.join(fixture.tempDir, 'existing-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; }); + // accessSync succeeds (writable), but nearest ancestor is still reported as blocker + + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow( + new RegExp(`Blocked by: .*existing-parent`) + ); + }); + + it('falls back to dirPath in Blocked by when no existing ancestor is found', () => { + const targetDir = path.join(fixture.tempDir, 'deep', 'nonexistent', 'dir'); + + const eacces = Object.assign( + new Error(`EACCES: permission denied, mkdir '${targetDir}'`), + { code: 'EACCES' } + ); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw eacces; }); + (fs.existsSync as jest.Mock).mockImplementation(() => false); + + try { + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow( + new RegExp(`Blocked by: ${targetDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + } finally { + (fs.existsSync as jest.Mock).mockImplementation( + (...args: Parameters) => actualFs.existsSync(...args) + ); + } + }); + + it('checks W_OK | X_OK when probing the blocking ancestor', () => { + const parentDir = path.join(fixture.tempDir, 'stale-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; }); + + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow(/EACCES/); + expect(fs.accessSync as jest.Mock).toHaveBeenCalledWith( + expect.any(String), + actualFs.constants.W_OK | actualFs.constants.X_OK + ); + }); + + it('rethrows non-EACCES errors from mkdirSync unchanged', () => { + const targetDir = path.join(fixture.tempDir, 'some-dir'); + const enoent = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + (fs.mkdirSync as jest.Mock).mockImplementationOnce(() => { throw enoent; }); + + expect(() => workdirSetupTestHelpers.ensureDirectory(targetDir)).toThrow(enoent); + }); +}); diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts index b7066a8a7..640a5a884 100644 --- a/src/workdir-setup.ts +++ b/src/workdir-setup.ts @@ -15,9 +15,36 @@ interface EnsureDirectoryOptions { function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): boolean { const { mode, onCreate, onExists, onAfterEnsure } = options; - const created = Boolean( - fs.mkdirSync(dirPath, mode === undefined ? { recursive: true } : { recursive: true, mode }) - ); + 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()) {