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
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,10 @@ program
'--audit-dir <path>',
'Directory for firewall audit artifacts (configs, policy manifest, iptables state)'
)
.option(
'--session-state-dir <path>',
'Directory to save Copilot CLI session state (events.jsonl, session data)'
)
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
.action(async (args: string[], options) => {
// Require -- separator for passing command arguments
Expand Down Expand Up @@ -1726,6 +1730,7 @@ program
memoryLimit: memoryLimit.value,
proxyLogsDir: options.proxyLogsDir,
auditDir: options.auditDir || process.env.AWF_AUDIT_DIR,
sessionStateDir: options.sessionStateDir || process.env.AWF_SESSION_STATE_DIR,
enableHostAccess: options.enableHostAccess,
localhostDetected: localhostResult.localhostDetected,
allowHostPorts: options.allowHostPorts,
Expand Down Expand Up @@ -1874,7 +1879,7 @@ program
}

if (!config.keepContainers) {
await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir);
await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir, config.sessionStateDir);
// Note: We don't remove the firewall network here since it can be reused
// across multiple runs. Cleanup script will handle removal if needed.
} else {
Expand Down
45 changes: 45 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,19 @@ describe('docker-manager', () => {
// CLI state directories
expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`);
expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`);
// ~/.copilot is mounted from host, with session-state and logs overlaid from AWF workDir
expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`);
expect(volumes).toContain(`/tmp/awf-test/agent-session-state:/host${homeDir}/.copilot/session-state:rw`);
expect(volumes).toContain(`/tmp/awf-test/agent-logs:/host${homeDir}/.copilot/logs:rw`);
});

it('should use sessionStateDir when specified for chroot mounts', () => {
const configWithSessionDir = { ...mockConfig, sessionStateDir: '/custom/session-state' };
const result = generateDockerCompose(configWithSessionDir, mockNetworkConfig);
const volumes = result.services.agent.volumes as string[];
const homeDir = process.env.HOME || '/root';
expect(volumes).toContain(`/custom/session-state:/host${homeDir}/.copilot/session-state:rw`);
expect(volumes).toContain(`/custom/session-state:${homeDir}/.copilot/session-state:rw`);
});

it('should add SYS_CHROOT and SYS_ADMIN capabilities but NOT NET_ADMIN', () => {
Expand Down Expand Up @@ -3280,6 +3292,39 @@ describe('docker-manager', () => {
// Should not throw
await expect(cleanup(nonExistentDir, false)).resolves.not.toThrow();
});

it('should preserve session state to /tmp when sessionStateDir is not specified', async () => {
const sessionStateDir = path.join(testDir, 'agent-session-state');
const sessionDir = path.join(sessionStateDir, 'abc-123');
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(path.join(sessionDir, 'events.jsonl'), '{"event":"test"}');

await cleanup(testDir, false);

// Verify session state was moved to timestamped /tmp directory
const timestamp = path.basename(testDir).replace('awf-', '');
const preservedDir = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
expect(fs.existsSync(preservedDir)).toBe(true);
expect(fs.existsSync(path.join(preservedDir, 'abc-123', 'events.jsonl'))).toBe(true);
});

it('should chmod session state in-place when sessionStateDir is specified', async () => {
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-session-test-'));
const sessionStateDir = path.join(externalDir, 'session-state');
fs.mkdirSync(sessionStateDir, { recursive: true });
fs.writeFileSync(path.join(sessionStateDir, 'events.jsonl'), '{"event":"test"}');

try {
await cleanup(testDir, false, undefined, undefined, sessionStateDir);

// Verify chmod was called on sessionStateDir (not moved)
expect(mockExecaSync).toHaveBeenCalledWith('chmod', ['-R', 'a+rX', sessionStateDir]);
// Files should remain in-place
expect(fs.existsSync(path.join(sessionStateDir, 'events.jsonl'))).toBe(true);
} finally {
fs.rmSync(externalDir, { recursive: true, force: true });
}
});
});

describe('readGitHubPathEntries', () => {
Expand Down
76 changes: 57 additions & 19 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ export function generateDockerCompose(
// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;

// Session state path: use sessionStateDir if specified (timeout-safe, predictable path),
// otherwise workDir/agent-session-state (will be moved to /tmp after cleanup)
const sessionStatePath = config.sessionStateDir || `${config.workDir}/agent-session-state`;

// Agent logs path: always workDir/agent-logs (moved to /tmp after cleanup)
const agentLogsPath = `${config.workDir}/agent-logs`;

// API proxy logs path: if proxyLogsDir is specified, write inside it as a subdirectory
// so that token-usage.jsonl is included in the firewall-audit-logs artifact automatically.
// Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup)
Expand Down Expand Up @@ -771,10 +778,10 @@ export function generateDockerCompose(
// Mount only the workspace directory (not entire HOME)
// This prevents access to ~/.docker/, ~/.config/gh/, ~/.npmrc, etc.
`${workspaceDir}:${workspaceDir}:rw`,
// Mount agent logs directory to workDir for persistence
`${config.workDir}/agent-logs:${effectiveHome}/.copilot/logs:rw`,
// Mount agent session-state directory to workDir for persistence (events.jsonl)
`${config.workDir}/agent-session-state:${effectiveHome}/.copilot/session-state:rw`,
// Mount agent logs directory for persistence
`${agentLogsPath}:${effectiveHome}/.copilot/logs:rw`,
// Mount agent session-state directory for persistence (events.jsonl, session data)
`${sessionStatePath}:${effectiveHome}/.copilot/session-state:rw`,
// Init signal volume for iptables init container coordination
`${initSignalDir}:/tmp/awf-init:rw`,
];
Expand Down Expand Up @@ -832,10 +839,18 @@ export function generateDockerCompose(
// - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so
agentVolumes.push('/tmp:/host/tmp:rw');

// Mount ~/.copilot for GitHub Copilot CLI (package extraction, config, logs)
// This is safe as ~/.copilot contains only Copilot CLI state, not credentials
// Mount ~/.copilot for Copilot CLI (package extraction, MCP config, etc.)
// This is safe as ~/.copilot contains only Copilot CLI state, not credentials.
// Auth tokens are in COPILOT_GITHUB_TOKEN env var (handled by API proxy sidecar).
agentVolumes.push(`${effectiveHome}/.copilot:/host${effectiveHome}/.copilot:rw`);

// Overlay session-state and logs from AWF workDir so events.jsonl and logs are
// captured in the workDir instead of written to the host's ~/.copilot.
// Docker processes mounts in order — these shadow the corresponding paths under
// the blanket ~/.copilot mount above.
agentVolumes.push(`${sessionStatePath}:/host${effectiveHome}/.copilot/session-state:rw`);
agentVolumes.push(`${agentLogsPath}:/host${effectiveHome}/.copilot/logs:rw`);

// Mount ~/.cache, ~/.config, ~/.local for CLI tool state management (Claude Code, etc.)
// These directories are safe to mount as they contain application state, not credentials
// Note: Specific credential files within ~/.config (like ~/.config/gh/hosts.yml) are
Expand Down Expand Up @@ -1017,7 +1032,7 @@ export function generateDockerCompose(
//
// Instead of mounting the entire $HOME directory (which contained credentials), we now:
// 1. Mount ONLY the workspace directory ($GITHUB_WORKSPACE or cwd)
// 2. Mount ~/.copilot/logs separately for Copilot CLI logging
// 2. Mount ~/.copilot with session-state and logs overlaid from AWF workDir
// 3. Hide credential files by mounting /dev/null over them (defense-in-depth)
// 4. Allow users to add specific mounts via --mount flag
//
Expand Down Expand Up @@ -1582,17 +1597,27 @@ export async function writeConfigs(config: WrapperConfig): Promise<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)
const agentLogsDir = path.join(config.workDir, 'agent-logs');
if (!fs.existsSync(agentLogsDir)) {
fs.mkdirSync(agentLogsDir, { recursive: true });
}
try {
fs.chownSync(agentLogsDir, parseInt(getSafeHostUid()), parseInt(getSafeHostGid()));
} catch { /* ignore chown failures in non-root context */ }
logger.debug(`Agent logs directory created at: ${agentLogsDir}`);

// Create agent session-state directory for persistence (events.jsonl written by Copilot CLI)
const agentSessionStateDir = path.join(config.workDir, 'agent-session-state');
// 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
const agentSessionStateDir = config.sessionStateDir || path.join(config.workDir, 'agent-session-state');
if (!fs.existsSync(agentSessionStateDir)) {
fs.mkdirSync(agentSessionStateDir, { recursive: true });
}
try {
fs.chownSync(agentSessionStateDir, parseInt(getSafeHostUid()), parseInt(getSafeHostGid()));
} catch { /* ignore chown failures in non-root context */ }
logger.debug(`Agent session-state directory created at: ${agentSessionStateDir}`);

// Create squid logs directory for persistence
Expand Down Expand Up @@ -2132,7 +2157,7 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void
}
}

export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir?: string, auditDir?: string): Promise<void> {
export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir?: string, auditDir?: string, sessionStateDir?: string): Promise<void> {
if (keepFiles) {
logger.debug(`Keeping temporary files in: ${workDir}`);
return;
Expand All @@ -2159,15 +2184,28 @@ export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir?
}
}

// Preserve agent session-state before cleanup (contains events.jsonl from Copilot CLI)
const agentSessionStateDir = path.join(workDir, 'agent-session-state');
const agentSessionStateDestination = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
if (fs.existsSync(agentSessionStateDir) && fs.readdirSync(agentSessionStateDir).length > 0) {
try {
fs.renameSync(agentSessionStateDir, agentSessionStateDestination);
logger.info(`Agent session state preserved at: ${agentSessionStateDestination}`);
} catch (error) {
logger.debug('Could not preserve agent session state:', error);
// Preserve agent session-state (contains events.jsonl, session data from Copilot CLI)
if (sessionStateDir) {
// Session state was written directly to sessionStateDir during runtime (timeout-safe)
// Just fix permissions so they're readable for artifact upload
if (fs.existsSync(sessionStateDir)) {
try {
execa.sync('chmod', ['-R', 'a+rX', sessionStateDir]);
logger.info(`Agent session state available at: ${sessionStateDir}`);
} catch (error) {
logger.debug('Could not fix session state permissions:', error);
}
}
} else {
const agentSessionStateDir = path.join(workDir, 'agent-session-state');
const agentSessionStateDestination = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
if (fs.existsSync(agentSessionStateDir) && fs.readdirSync(agentSessionStateDir).length > 0) {
try {
fs.renameSync(agentSessionStateDir, agentSessionStateDestination);
logger.info(`Agent session state preserved at: ${agentSessionStateDestination}`);
} catch (error) {
logger.debug('Could not preserve agent session state:', error);
}
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,24 @@ export interface WrapperConfig {
*/
auditDir?: string;

/**
* Directory for agent session state (Copilot CLI events.jsonl, session data)
*
* When specified, the session-state volume is written directly to this
* directory during execution, making it timeout-safe and available at a
* predictable path for artifact upload.
*
* When not specified, session state is written to ${workDir}/agent-session-state
* during runtime and moved to /tmp/awf-agent-session-state-<timestamp> after cleanup.
*
* Can be set via:
* - CLI flag: `--session-state-dir <path>`
* - Environment variable: `AWF_SESSION_STATE_DIR`
*
* @example '/tmp/gh-aw/sandbox/agent/session-state'
*/
sessionStateDir?: string;

/**
* Enable access to host services via host.docker.internal
*
Expand Down
Loading