Skip to content

Commit 6536923

Browse files
lpcoxCopilot
andauthored
fix: capture full session state — replace blanket ~/.copilot mount, add --session-state-dir (#1593)
* fix: replace blanket ~/.copilot chroot mount with targeted session-state and logs mounts 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> * 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-<timestamp> during cleanup Also supports AWF_SESSION_STATE_DIR environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * 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> * fix: keep ~/.copilot host mount with session-state/logs overlays 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> * 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> * 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> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c010d4a commit 6536923

4 files changed

Lines changed: 126 additions & 20 deletions

File tree

src/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,10 @@ program
14151415
'--audit-dir <path>',
14161416
'Directory for firewall audit artifacts (configs, policy manifest, iptables state)'
14171417
)
1418+
.option(
1419+
'--session-state-dir <path>',
1420+
'Directory to save Copilot CLI session state (events.jsonl, session data)'
1421+
)
14181422
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
14191423
.action(async (args: string[], options) => {
14201424
// Require -- separator for passing command arguments
@@ -1726,6 +1730,7 @@ program
17261730
memoryLimit: memoryLimit.value,
17271731
proxyLogsDir: options.proxyLogsDir,
17281732
auditDir: options.auditDir || process.env.AWF_AUDIT_DIR,
1733+
sessionStateDir: options.sessionStateDir || process.env.AWF_SESSION_STATE_DIR,
17291734
enableHostAccess: options.enableHostAccess,
17301735
localhostDetected: localhostResult.localhostDetected,
17311736
allowHostPorts: options.allowHostPorts,
@@ -1874,7 +1879,7 @@ program
18741879
}
18751880

18761881
if (!config.keepContainers) {
1877-
await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir);
1882+
await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir, config.sessionStateDir);
18781883
// Note: We don't remove the firewall network here since it can be reused
18791884
// across multiple runs. Cleanup script will handle removal if needed.
18801885
} else {

src/docker-manager.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,19 @@ describe('docker-manager', () => {
784784
// CLI state directories
785785
expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`);
786786
expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`);
787+
// ~/.copilot is mounted from host, with session-state and logs overlaid from AWF workDir
787788
expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`);
789+
expect(volumes).toContain(`/tmp/awf-test/agent-session-state:/host${homeDir}/.copilot/session-state:rw`);
790+
expect(volumes).toContain(`/tmp/awf-test/agent-logs:/host${homeDir}/.copilot/logs:rw`);
791+
});
792+
793+
it('should use sessionStateDir when specified for chroot mounts', () => {
794+
const configWithSessionDir = { ...mockConfig, sessionStateDir: '/custom/session-state' };
795+
const result = generateDockerCompose(configWithSessionDir, mockNetworkConfig);
796+
const volumes = result.services.agent.volumes as string[];
797+
const homeDir = process.env.HOME || '/root';
798+
expect(volumes).toContain(`/custom/session-state:/host${homeDir}/.copilot/session-state:rw`);
799+
expect(volumes).toContain(`/custom/session-state:${homeDir}/.copilot/session-state:rw`);
788800
});
789801

790802
it('should add SYS_CHROOT and SYS_ADMIN capabilities but NOT NET_ADMIN', () => {
@@ -3280,6 +3292,39 @@ describe('docker-manager', () => {
32803292
// Should not throw
32813293
await expect(cleanup(nonExistentDir, false)).resolves.not.toThrow();
32823294
});
3295+
3296+
it('should preserve session state to /tmp when sessionStateDir is not specified', async () => {
3297+
const sessionStateDir = path.join(testDir, 'agent-session-state');
3298+
const sessionDir = path.join(sessionStateDir, 'abc-123');
3299+
fs.mkdirSync(sessionDir, { recursive: true });
3300+
fs.writeFileSync(path.join(sessionDir, 'events.jsonl'), '{"event":"test"}');
3301+
3302+
await cleanup(testDir, false);
3303+
3304+
// Verify session state was moved to timestamped /tmp directory
3305+
const timestamp = path.basename(testDir).replace('awf-', '');
3306+
const preservedDir = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
3307+
expect(fs.existsSync(preservedDir)).toBe(true);
3308+
expect(fs.existsSync(path.join(preservedDir, 'abc-123', 'events.jsonl'))).toBe(true);
3309+
});
3310+
3311+
it('should chmod session state in-place when sessionStateDir is specified', async () => {
3312+
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-session-test-'));
3313+
const sessionStateDir = path.join(externalDir, 'session-state');
3314+
fs.mkdirSync(sessionStateDir, { recursive: true });
3315+
fs.writeFileSync(path.join(sessionStateDir, 'events.jsonl'), '{"event":"test"}');
3316+
3317+
try {
3318+
await cleanup(testDir, false, undefined, undefined, sessionStateDir);
3319+
3320+
// Verify chmod was called on sessionStateDir (not moved)
3321+
expect(mockExecaSync).toHaveBeenCalledWith('chmod', ['-R', 'a+rX', sessionStateDir]);
3322+
// Files should remain in-place
3323+
expect(fs.existsSync(path.join(sessionStateDir, 'events.jsonl'))).toBe(true);
3324+
} finally {
3325+
fs.rmSync(externalDir, { recursive: true, force: true });
3326+
}
3327+
});
32833328
});
32843329

32853330
describe('readGitHubPathEntries', () => {

src/docker-manager.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,13 @@ export function generateDockerCompose(
384384
// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
385385
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;
386386

387+
// Session state path: use sessionStateDir if specified (timeout-safe, predictable path),
388+
// otherwise workDir/agent-session-state (will be moved to /tmp after cleanup)
389+
const sessionStatePath = config.sessionStateDir || `${config.workDir}/agent-session-state`;
390+
391+
// Agent logs path: always workDir/agent-logs (moved to /tmp after cleanup)
392+
const agentLogsPath = `${config.workDir}/agent-logs`;
393+
387394
// API proxy logs path: if proxyLogsDir is specified, write inside it as a subdirectory
388395
// so that token-usage.jsonl is included in the firewall-audit-logs artifact automatically.
389396
// Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup)
@@ -771,10 +778,10 @@ export function generateDockerCompose(
771778
// Mount only the workspace directory (not entire HOME)
772779
// This prevents access to ~/.docker/, ~/.config/gh/, ~/.npmrc, etc.
773780
`${workspaceDir}:${workspaceDir}:rw`,
774-
// Mount agent logs directory to workDir for persistence
775-
`${config.workDir}/agent-logs:${effectiveHome}/.copilot/logs:rw`,
776-
// Mount agent session-state directory to workDir for persistence (events.jsonl)
777-
`${config.workDir}/agent-session-state:${effectiveHome}/.copilot/session-state:rw`,
781+
// Mount agent logs directory for persistence
782+
`${agentLogsPath}:${effectiveHome}/.copilot/logs:rw`,
783+
// Mount agent session-state directory for persistence (events.jsonl, session data)
784+
`${sessionStatePath}:${effectiveHome}/.copilot/session-state:rw`,
778785
// Init signal volume for iptables init container coordination
779786
`${initSignalDir}:/tmp/awf-init:rw`,
780787
];
@@ -832,10 +839,18 @@ export function generateDockerCompose(
832839
// - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so
833840
agentVolumes.push('/tmp:/host/tmp:rw');
834841

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

847+
// Overlay session-state and logs from AWF workDir so events.jsonl and logs are
848+
// captured in the workDir instead of written to the host's ~/.copilot.
849+
// Docker processes mounts in order — these shadow the corresponding paths under
850+
// the blanket ~/.copilot mount above.
851+
agentVolumes.push(`${sessionStatePath}:/host${effectiveHome}/.copilot/session-state:rw`);
852+
agentVolumes.push(`${agentLogsPath}:/host${effectiveHome}/.copilot/logs:rw`);
853+
839854
// Mount ~/.cache, ~/.config, ~/.local for CLI tool state management (Claude Code, etc.)
840855
// These directories are safe to mount as they contain application state, not credentials
841856
// Note: Specific credential files within ~/.config (like ~/.config/gh/hosts.yml) are
@@ -1017,7 +1032,7 @@ export function generateDockerCompose(
10171032
//
10181033
// Instead of mounting the entire $HOME directory (which contained credentials), we now:
10191034
// 1. Mount ONLY the workspace directory ($GITHUB_WORKSPACE or cwd)
1020-
// 2. Mount ~/.copilot/logs separately for Copilot CLI logging
1035+
// 2. Mount ~/.copilot with session-state and logs overlaid from AWF workDir
10211036
// 3. Hide credential files by mounting /dev/null over them (defense-in-depth)
10221037
// 4. Allow users to add specific mounts via --mount flag
10231038
//
@@ -1582,17 +1597,27 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
15821597
}
15831598

15841599
// Create agent logs directory for persistence
1600+
// Chown to host user so Copilot CLI can write logs (AWF runs as root, agent runs as host user)
15851601
const agentLogsDir = path.join(config.workDir, 'agent-logs');
15861602
if (!fs.existsSync(agentLogsDir)) {
15871603
fs.mkdirSync(agentLogsDir, { recursive: true });
15881604
}
1605+
try {
1606+
fs.chownSync(agentLogsDir, parseInt(getSafeHostUid()), parseInt(getSafeHostGid()));
1607+
} catch { /* ignore chown failures in non-root context */ }
15891608
logger.debug(`Agent logs directory created at: ${agentLogsDir}`);
15901609

1591-
// Create agent session-state directory for persistence (events.jsonl written by Copilot CLI)
1592-
const agentSessionStateDir = path.join(config.workDir, 'agent-session-state');
1610+
// Create agent session-state directory for persistence (events.jsonl, session data)
1611+
// If sessionStateDir is specified, write directly there (timeout-safe, predictable path)
1612+
// Otherwise, use workDir/agent-session-state (will be moved to /tmp after cleanup)
1613+
// Chown to host user so Copilot CLI can create session subdirs and write events.jsonl
1614+
const agentSessionStateDir = config.sessionStateDir || path.join(config.workDir, 'agent-session-state');
15931615
if (!fs.existsSync(agentSessionStateDir)) {
15941616
fs.mkdirSync(agentSessionStateDir, { recursive: true });
15951617
}
1618+
try {
1619+
fs.chownSync(agentSessionStateDir, parseInt(getSafeHostUid()), parseInt(getSafeHostGid()));
1620+
} catch { /* ignore chown failures in non-root context */ }
15961621
logger.debug(`Agent session-state directory created at: ${agentSessionStateDir}`);
15971622

15981623
// Create squid logs directory for persistence
@@ -2132,7 +2157,7 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void
21322157
}
21332158
}
21342159

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

2162-
// Preserve agent session-state before cleanup (contains events.jsonl from Copilot CLI)
2163-
const agentSessionStateDir = path.join(workDir, 'agent-session-state');
2164-
const agentSessionStateDestination = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
2165-
if (fs.existsSync(agentSessionStateDir) && fs.readdirSync(agentSessionStateDir).length > 0) {
2166-
try {
2167-
fs.renameSync(agentSessionStateDir, agentSessionStateDestination);
2168-
logger.info(`Agent session state preserved at: ${agentSessionStateDestination}`);
2169-
} catch (error) {
2170-
logger.debug('Could not preserve agent session state:', error);
2187+
// Preserve agent session-state (contains events.jsonl, session data from Copilot CLI)
2188+
if (sessionStateDir) {
2189+
// Session state was written directly to sessionStateDir during runtime (timeout-safe)
2190+
// Just fix permissions so they're readable for artifact upload
2191+
if (fs.existsSync(sessionStateDir)) {
2192+
try {
2193+
execa.sync('chmod', ['-R', 'a+rX', sessionStateDir]);
2194+
logger.info(`Agent session state available at: ${sessionStateDir}`);
2195+
} catch (error) {
2196+
logger.debug('Could not fix session state permissions:', error);
2197+
}
2198+
}
2199+
} else {
2200+
const agentSessionStateDir = path.join(workDir, 'agent-session-state');
2201+
const agentSessionStateDestination = path.join(os.tmpdir(), `awf-agent-session-state-${timestamp}`);
2202+
if (fs.existsSync(agentSessionStateDir) && fs.readdirSync(agentSessionStateDir).length > 0) {
2203+
try {
2204+
fs.renameSync(agentSessionStateDir, agentSessionStateDestination);
2205+
logger.info(`Agent session state preserved at: ${agentSessionStateDestination}`);
2206+
} catch (error) {
2207+
logger.debug('Could not preserve agent session state:', error);
2208+
}
21712209
}
21722210
}
21732211

src/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,24 @@ export interface WrapperConfig {
390390
*/
391391
auditDir?: string;
392392

393+
/**
394+
* Directory for agent session state (Copilot CLI events.jsonl, session data)
395+
*
396+
* When specified, the session-state volume is written directly to this
397+
* directory during execution, making it timeout-safe and available at a
398+
* predictable path for artifact upload.
399+
*
400+
* When not specified, session state is written to ${workDir}/agent-session-state
401+
* during runtime and moved to /tmp/awf-agent-session-state-<timestamp> after cleanup.
402+
*
403+
* Can be set via:
404+
* - CLI flag: `--session-state-dir <path>`
405+
* - Environment variable: `AWF_SESSION_STATE_DIR`
406+
*
407+
* @example '/tmp/gh-aw/sandbox/agent/session-state'
408+
*/
409+
sessionStateDir?: string;
410+
393411
/**
394412
* Enable access to host services via host.docker.internal
395413
*

0 commit comments

Comments
 (0)