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
38 changes: 21 additions & 17 deletions containers/api-proxy/server.websocket.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
}));
});
});
});
});
Expand Down
106 changes: 105 additions & 1 deletion src/config-writer-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof fs.existsSync>) => jest.requireActual<typeof import('fs')>('fs').existsSync(...args)
);
(fs.statSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof fs.statSync>) => jest.requireActual<typeof import('fs')>('fs').statSync(...args)
);
(fs.accessSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof fs.accessSync>) => jest.requireActual<typeof import('fs')>('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<typeof fs.existsSync>) => jest.requireActual<typeof import('fs')>('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<typeof import('fs')>('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<typeof fs.existsSync>) => jest.requireActual<typeof import('fs')>('fs').existsSync(...args)
);
(fs.statSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof fs.statSync>) => jest.requireActual<typeof import('fs')>('fs').statSync(...args)
);
(fs.accessSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof fs.accessSync>) => jest.requireActual<typeof import('fs')>('fs').accessSync(...args)
);
}
});
});
});
90 changes: 77 additions & 13 deletions src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`
);
Comment on lines +116 to +123
}
throw error;
Comment on lines +97 to +125
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/test-helpers/config-writer-test-harness.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('fs')>('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<typeof actualFs.mkdirSync>) => actualFs.mkdirSync(...args),
);
(fs.accessSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof actualFs.accessSync>) => actualFs.accessSync(...args),
);
(fs.statSync as jest.Mock).mockImplementation(
(...args: Parameters<typeof actualFs.statSync>) => actualFs.statSync(...args),
);
(getRealUserHome as jest.Mock).mockReturnValue(tempDir);
return tempDir;
}
Expand Down
3 changes: 3 additions & 0 deletions src/test-helpers/fs-mock-factory.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export function fsMockFactory() {
const actual = jest.requireActual<typeof import('fs')>('fs');
return {
...actual,
mkdirSync: jest.fn((...args: Parameters<typeof actual.mkdirSync>) => actual.mkdirSync(...args)) as typeof actual.mkdirSync,
accessSync: jest.fn((...args: Parameters<typeof actual.accessSync>) => actual.accessSync(...args)) as typeof actual.accessSync,
statSync: jest.fn((...args: Parameters<typeof actual.statSync>) => actual.statSync(...args)) as typeof actual.statSync,
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
Loading
Loading