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
4 changes: 2 additions & 2 deletions .github/workflows/test-chroot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ jobs:
name: Test Chroot /proc Filesystem
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test-chroot-languages # Run after language tests pass
# No dependency on languages - runs in parallel for faster CI

steps:
- name: Checkout repository
Expand Down Expand Up @@ -347,7 +347,7 @@ jobs:
name: Test Chroot Edge Cases
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test-chroot-languages # Run after language tests pass
# No dependency on languages - runs in parallel for faster CI

steps:
- name: Checkout repository
Expand Down
117 changes: 117 additions & 0 deletions tests/fixtures/batch-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Batch Runner - runs multiple commands in a single AWF container invocation.
*
* Each test that calls runner.runWithSudo() spawns a full Docker container
* lifecycle (~15-25s overhead). This utility batches commands that share the
* same allowDomains config into one invocation, cutting container startups
* from ~73 to ~27 across the chroot test suite.
*
* Usage:
* const results = await runBatch(runner, [
* { name: 'python_version', command: 'python3 --version' },
* { name: 'node_version', command: 'node --version' },
* ], { allowDomains: ['github.com'] });
*
* // Each test asserts against its own result:
* expect(results.get('python_version').exitCode).toBe(0);
*/

import { AwfRunner, AwfOptions, AwfResult } from './awf-runner';

export interface BatchCommand {
name: string;
command: string;
}

export interface BatchCommandResult {
stdout: string;
exitCode: number;
}

export interface BatchResults {
/** Get result for a named command. Throws if name not found. */
get(name: string): BatchCommandResult;
/** The raw AWF result for the entire batch invocation. */
overall: AwfResult;
}

// Delimiter tokens – chosen to be unlikely in real command output
const START = '===BATCH_START:';
const EXIT = '===BATCH_EXIT:';
const DELIM_END = '===';

/**
* Build a bash script that runs each command in a subshell, capturing its
* exit code and delimiting its output.
*/
function generateScript(commands: BatchCommand[]): string {
return commands.map(cmd => {
// Each command runs in a subshell so failures don't abort the batch.
// stdout and stderr are merged (2>&1) so we capture everything.
// IMPORTANT: capture $? immediately into _EC before echo resets it.
return [
`echo "${START}${cmd.name}${DELIM_END}"`,
`(${cmd.command}) 2>&1`,
`_EC=$?`,
`echo ""`,
`echo "${EXIT}${cmd.name}:$_EC${DELIM_END}"`,
].join('; ');
}).join('; ');
}

/**
* Parse the combined stdout into per-command results.
*/
function parseResults(stdout: string, commands: BatchCommand[]): Map<string, BatchCommandResult> {
const results = new Map<string, BatchCommandResult>();

for (const cmd of commands) {
const startToken = `${START}${cmd.name}${DELIM_END}`;
const exitPattern = new RegExp(`${EXIT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${cmd.name}:(\\d+)${DELIM_END}`);

const startIdx = stdout.indexOf(startToken);
const exitMatch = stdout.match(exitPattern);

if (startIdx === -1 || !exitMatch) {
// Command output not found – likely the batch was killed early
results.set(cmd.name, { stdout: '', exitCode: -1 });
continue;
}

const contentStart = startIdx + startToken.length;
const contentEnd = stdout.indexOf(exitMatch[0], contentStart) - 1; // -1 for the blank line
const cmdStdout = stdout.slice(contentStart, contentEnd).trim();
Comment on lines +72 to +83

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exitMatch is found via stdout.match(exitPattern), which returns the first match in the whole batch output. If commands contains duplicate names, the second instance will reuse the first instance's exit marker; then stdout.indexOf(exitMatch[0], contentStart) can be -1 and slicing will produce incorrect output/exitCode. Either enforce unique names up front (throw on duplicates) or change parsing to search for the exit marker after the corresponding start token.

Copilot uses AI. Check for mistakes.
const exitCode = parseInt(exitMatch[1], 10);

results.set(cmd.name, { stdout: cmdStdout, exitCode });
}

return results;
}

/**
* Run multiple commands in a single AWF container invocation.
*
* All commands share the same AwfOptions (allowDomains, timeout, etc.).
* Individual command results are parsed from delimited output.
*/
export async function runBatch(
runner: AwfRunner,
commands: BatchCommand[],
options: AwfOptions,
): Promise<BatchResults> {
const script = generateScript(commands);
const result = await runner.runWithSudo(script, options);
const parsed = parseResults(result.stdout, commands);

return {
get(name: string): BatchCommandResult {
const r = parsed.get(name);
if (!r) {
throw new Error(`Batch command "${name}" not found in results. Available: ${[...parsed.keys()].join(', ')}`);
}
return r;
},
overall: result,
};
}
86 changes: 42 additions & 44 deletions tests/integration/chroot-copilot-home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,76 +9,74 @@
*
* The fix mounts ~/.copilot at /host~/.copilot in chroot mode to enable
* write access while maintaining security (no full HOME mount).
*
* OPTIMIZATION: All 3 tests share the same allowDomains and are batched
* into a single AWF invocation. Reduces 3 invocations to 1.
*/

/// <reference path="../jest-custom-matchers.d.ts" />

import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import { createRunner, AwfRunner } from '../fixtures/awf-runner';
import { cleanup } from '../fixtures/cleanup';
import { runBatch, BatchResults } from '../fixtures/batch-runner';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

describe('Chroot Copilot Home Directory Access', () => {
let runner: AwfRunner;
let testCopilotDir: string;
let batch: BatchResults;

beforeAll(async () => {
await cleanup(false);
runner = createRunner();

// Ensure ~/.copilot exists on the host (as the workflow does)
testCopilotDir = path.join(os.homedir(), '.copilot');
const testCopilotDir = path.join(os.homedir(), '.copilot');
if (!fs.existsSync(testCopilotDir)) {
fs.mkdirSync(testCopilotDir, { recursive: true, mode: 0o755 });
}
});

afterAll(async () => {
await cleanup(false);
});

test('should be able to write to ~/.copilot directory', async () => {
const result = await runner.runWithSudo(
'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt',
batch = await runBatch(runner, [
{
allowDomains: ['localhost'],
logLevel: 'debug',
timeout: 60000,
}
);

expect(result).toSucceed();
expect(result.stdout).toContain('test-content');
name: 'write_file',
command: 'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt',
},
{
name: 'nested_dirs',
command: 'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt',
},
{
name: 'permissions',
command: 'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"',
},
], {
allowDomains: ['localhost'],
logLevel: 'debug',
timeout: 60000,
});
}, 120000);

test('should be able to create nested directories in ~/.copilot', async () => {
// Simulate what Copilot CLI does: create pkg/linux-x64/VERSION
const result = await runner.runWithSudo(
'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt',
{
allowDomains: ['localhost'],
logLevel: 'debug',
timeout: 60000,
}
);
afterAll(async () => {
await cleanup(false);
});

expect(result).toSucceed();
expect(result.stdout).toContain('package-extracted');
}, 120000);
test('should be able to write to ~/.copilot directory', () => {
const r = batch.get('write_file');
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('test-content');
});

test('should verify ~/.copilot is writable with correct permissions', async () => {
const result = await runner.runWithSudo(
'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"',
{
allowDomains: ['localhost'],
logLevel: 'debug',
timeout: 60000,
}
);
test('should be able to create nested directories in ~/.copilot', () => {
const r = batch.get('nested_dirs');
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('package-extracted');
});

expect(result).toSucceed();
expect(result.stdout).toContain('write-success');
}, 120000);
test('should verify ~/.copilot is writable with correct permissions', () => {
const r = batch.get('permissions');
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('write-success');
});
});
Loading
Loading