From db0d0a790c00d37d3cdb5ef6a803de02ded4009c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 18:31:31 -0700 Subject: [PATCH 1/6] fix: replace blanket ~/.copilot chroot mount with targeted session-state and logs mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket ~/.copilot bind mount at the chroot path (/host$HOME/.copilot) shadowed the pre-chroot AWF workDir volume mounts for session-state and logs. After chroot, Copilot CLI wrote events.jsonl to the host filesystem via the bind mount, bypassing the AWF workDir entirely. This prevented events.jsonl from being captured in workflow artifacts. Replace the single ~/.copilot mount with two targeted mounts that map AWF workDir subdirectories (agent-session-state, agent-logs) to the chroot paths. This ensures: - events.jsonl is captured in AWF workDir (fixable in artifacts) - Agent logs go through AWF workDir (consistent with non-chroot mode) - Host ~/.copilot contents (config, auth state) are no longer exposed to the sandboxed agent — it gets an empty writable ~/.copilot from the empty home volume instead Fixes #1592 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.test.ts | 6 +++++- src/docker-manager.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index a3743f78..b32f6f41 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -784,7 +784,11 @@ describe('docker-manager', () => { // CLI state directories expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`); expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`); - expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`); + // ~/.copilot is NOT blanket-mounted (security: may contain config/auth state) + // Instead, session-state and logs are mounted from AWF workDir at chroot paths + expect(volumes).not.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 add SYS_CHROOT and SYS_ADMIN capabilities but NOT NET_ADMIN', () => { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 690d9355..c41e42c0 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -832,9 +832,14 @@ 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 - agentVolumes.push(`${effectiveHome}/.copilot:/host${effectiveHome}/.copilot:rw`); + // Mount AWF workDir session-state and logs at chroot paths so events.jsonl and + // logs are captured in the AWF workDir (not written to the host's ~/.copilot). + // We intentionally do NOT mount the entire ~/.copilot directory, because it may + // contain configuration or cached auth state that should not be exposed to the + // sandboxed agent. The empty home volume (above) provides a writable ~/.copilot + // for Copilot CLI to create any other files it needs. + agentVolumes.push(`${config.workDir}/agent-session-state:/host${effectiveHome}/.copilot/session-state:rw`); + agentVolumes.push(`${config.workDir}/agent-logs:/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 @@ -1017,7 +1022,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/logs and ~/.copilot/session-state from AWF workDir (not host) // 3. Hide credential files by mounting /dev/null over them (defense-in-depth) // 4. Allow users to add specific mounts via --mount flag // From 897cbec055fbd3255f49a04c2b8d6605975aa26f Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 19:37:54 -0700 Subject: [PATCH 2/6] feat: add --session-state-dir flag for timeout-safe session export Add a --session-state-dir option (following the --proxy-logs-dir pattern) that writes the entire Copilot CLI session directory tree directly to a predictable, external path. This makes the full session state (events.jsonl, session.db, plan.md, checkpoints, etc.) available for artifact upload without relying on post-cleanup /tmp paths. When specified: - Session state is written directly to the given path during execution - Permissions are fixed during cleanup (chmod -R a+rX) - The path survives AWF workDir deletion (timeout-safe) When not specified (default): - Session state goes to workDir/agent-session-state as before - Moved to /tmp/awf-agent-session-state- during cleanup Also supports AWF_SESSION_STATE_DIR environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli.ts | 7 ++++- src/docker-manager.test.ts | 9 ++++++ src/docker-manager.ts | 62 ++++++++++++++++++++++++++------------ src/types.ts | 18 +++++++++++ 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index eac89a81..074b0fb5 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 b32f6f41..d9ddbc8f 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -791,6 +791,15 @@ describe('docker-manager', () => { 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', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; diff --git a/src/docker-manager.ts b/src/docker-manager.ts index c41e42c0..a0428df5 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,14 +839,14 @@ export function generateDockerCompose( // - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so agentVolumes.push('/tmp:/host/tmp:rw'); - // Mount AWF workDir session-state and logs at chroot paths so events.jsonl and - // logs are captured in the AWF workDir (not written to the host's ~/.copilot). + // Mount session-state and logs at chroot paths so events.jsonl and logs are + // captured (not written to the host's ~/.copilot via a blanket bind mount). // We intentionally do NOT mount the entire ~/.copilot directory, because it may // contain configuration or cached auth state that should not be exposed to the // sandboxed agent. The empty home volume (above) provides a writable ~/.copilot // for Copilot CLI to create any other files it needs. - agentVolumes.push(`${config.workDir}/agent-session-state:/host${effectiveHome}/.copilot/session-state:rw`); - agentVolumes.push(`${config.workDir}/agent-logs:/host${effectiveHome}/.copilot/logs:rw`); + 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 @@ -1593,8 +1600,10 @@ export async function writeConfigs(config: WrapperConfig): Promise { } 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) + const agentSessionStateDir = config.sessionStateDir || path.join(config.workDir, 'agent-session-state'); if (!fs.existsSync(agentSessionStateDir)) { fs.mkdirSync(agentSessionStateDir, { recursive: true }); } @@ -2137,7 +2146,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; @@ -2164,15 +2173,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 077b396a..0f78e523 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 * From e296489b83b3534f4f2aab603b132c5356130b12 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 20:02:11 -0700 Subject: [PATCH 3/6] fix: pre-create .copilot in empty home volume for package extraction Copilot CLI needs to create ~/.copilot/pkg/ for package extraction. When the blanket ~/.copilot bind mount was replaced with targeted sub-mounts (session-state, logs), Docker auto-created the .copilot directory as root-owned to serve as a mount point. This caused EACCES when Copilot CLI tried to mkdir ~/.copilot/pkg as the runner user. Fix by pre-creating .copilot inside the empty home volume directory with correct UID/GID ownership, so it's writable after chroot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index a0428df5..e0541e60 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1682,6 +1682,20 @@ export async function writeConfigs(config: WrapperConfig): Promise { fs.chownSync(emptyHomeDir, uid, gid); logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); + // Pre-create subdirectories inside the empty home volume so they exist with + // correct ownership after chroot. Without this, Docker auto-creates them as + // root-owned when mounting child volumes (e.g., .copilot/session-state), + // preventing user-level writes to sibling paths (e.g., .copilot/pkg). + const emptyHomeDirs = ['.copilot']; + for (const dir of emptyHomeDirs) { + const dirPath = path.join(emptyHomeDir, dir); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + fs.chownSync(dirPath, uid, gid); + logger.debug(`Created chroot home subdirectory: ${dirPath} (${uid}:${gid})`); + } + } + // Ensure source directories for subdirectory mounts exist with correct ownership const chrootHomeDirs = [ '.copilot', '.cache', '.config', '.local', From 7d7284630fbfa34ba233c2ff8cc1396cc169ebdc Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 20:13:35 -0700 Subject: [PATCH 4/6] fix: keep ~/.copilot host mount with session-state/logs overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the blanket ~/.copilot mount broke MCP config access and package extraction. Copilot CLI needs: - ~/.copilot/mcp-config.json (MCP server config, written by gh-aw framework) - ~/.copilot/pkg/ (package extraction during startup) Restore the host ~/.copilot bind mount and overlay session-state and logs from the AWF workDir on top. Docker processes mounts in order, so the later session-state and logs mounts shadow the corresponding paths under the parent ~/.copilot mount. Result: - MCP config and packages accessible from host (as before) - session-state → AWF workDir (events.jsonl captured) - logs → AWF workDir (agent logs captured) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.test.ts | 5 ++--- src/docker-manager.ts | 31 ++++++++++--------------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index d9ddbc8f..cff40739 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -784,9 +784,8 @@ 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 NOT blanket-mounted (security: may contain config/auth state) - // Instead, session-state and logs are mounted from AWF workDir at chroot paths - expect(volumes).not.toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot: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`); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e0541e60..c9993421 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -839,12 +839,15 @@ export function generateDockerCompose( // - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so agentVolumes.push('/tmp:/host/tmp:rw'); - // Mount session-state and logs at chroot paths so events.jsonl and logs are - // captured (not written to the host's ~/.copilot via a blanket bind mount). - // We intentionally do NOT mount the entire ~/.copilot directory, because it may - // contain configuration or cached auth state that should not be exposed to the - // sandboxed agent. The empty home volume (above) provides a writable ~/.copilot - // for Copilot CLI to create any other files it needs. + // 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`); @@ -1029,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 and ~/.copilot/session-state from AWF workDir (not host) + // 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 // @@ -1682,20 +1685,6 @@ export async function writeConfigs(config: WrapperConfig): Promise { fs.chownSync(emptyHomeDir, uid, gid); logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); - // Pre-create subdirectories inside the empty home volume so they exist with - // correct ownership after chroot. Without this, Docker auto-creates them as - // root-owned when mounting child volumes (e.g., .copilot/session-state), - // preventing user-level writes to sibling paths (e.g., .copilot/pkg). - const emptyHomeDirs = ['.copilot']; - for (const dir of emptyHomeDirs) { - const dirPath = path.join(emptyHomeDir, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - fs.chownSync(dirPath, uid, gid); - logger.debug(`Created chroot home subdirectory: ${dirPath} (${uid}:${gid})`); - } - } - // Ensure source directories for subdirectory mounts exist with correct ownership const chrootHomeDirs = [ '.copilot', '.cache', '.config', '.local', From a134e5cd71fffe18f0ad9757324bb46282c1969e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 20:20:42 -0700 Subject: [PATCH 5/6] fix: chown session-state and agent-logs dirs to host user AWF runs as root but the agent container runs as the host user (e.g., UID 1001). The session-state and agent-logs overlay directories were created by root and never chowned, so Copilot CLI could not create session subdirectories or write events.jsonl. Chown both directories to the host user's UID/GID after creation, matching how squid-logs are chowned to the proxy user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index c9993421..0bff71f7 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1597,19 +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, 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 From 84bd39ab7ff2d041d5def98fd39156f1b57e465d Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 1 Apr 2026 20:31:11 -0700 Subject: [PATCH 6/6] test: add cleanup tests for sessionStateDir branches Add tests covering the sessionStateDir conditional cleanup paths to fix the branch coverage regression (-0.10%): - Preserve session state to /tmp when sessionStateDir is not specified - Chmod session state in-place when sessionStateDir is specified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index cff40739..b193c7ce 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -3292,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', () => {