diff --git a/src/cli.ts b/src/cli.ts index eac89a811..074b0fb59 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1415,6 +1415,10 @@ program '--audit-dir ', 'Directory for firewall audit artifacts (configs, policy manifest, iptables state)' ) + .option( + '--session-state-dir ', + '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 @@ -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, @@ -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 { diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index a3743f78f..b193c7ce3 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -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', () => { @@ -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', () => { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 690d9355a..0bff71f79 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -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) @@ -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`, ]; @@ -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 @@ -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 // @@ -1582,17 +1597,27 @@ export async function writeConfigs(config: WrapperConfig): Promise { } // 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 @@ -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 { +export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir?: string, auditDir?: string, sessionStateDir?: string): Promise { if (keepFiles) { logger.debug(`Keeping temporary files in: ${workDir}`); return; @@ -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); + } } } diff --git a/src/types.ts b/src/types.ts index 077b396af..0f78e5232 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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- after cleanup. + * + * Can be set via: + * - CLI flag: `--session-state-dir ` + * - 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 *