Skip to content

Commit e5ee8bd

Browse files
Mossakaclaude
andauthored
feat: add --tty option for interactive tools and update docker config (#51)
* feat: add --tty option for interactive tools and update docker config Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * feat: add claude code integration tests and tty support Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * feat: enhance claude integration tests and improve command in cli Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * docs: document command argument handling for shell variables The args.length === 1 check is correct and necessary for preserving shell variables ($HOME, $(command), etc.) that must expand in the container, not on the host. Root cause analysis: - shell-quote library would expand variables on HOST machine - GitHub Actions commands contain $GITHUB_WORKSPACE, $HOME, etc - These must expand INSIDE the container, not before - Docker Compose $$$$ escaping requires literal $ preservation Solution: Keep single-argument passthrough, add comprehensive docs Changes: - Enhanced documentation in src/cli.ts explaining behavior - Added JSDoc to test runner explaining string vs array - Added test cases for variable preservation - Updated docs/usage.md with environment variable examples This ensures shell variables expand correctly in the container environment while maintaining backward compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add npm cache cleanup step Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * fix: update npm cache clean command to use sudo for consistency Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * fix: more ci pipeline failure fixes Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * ci: remove cache and summary Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> --------- Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15a4093 commit e5ee8bd

File tree

13 files changed

+527
-11
lines changed

13 files changed

+527
-11
lines changed

.github/workflows/test-claude.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Claude Code Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
test-claude-code:
15+
name: Claude Code Integration Tests
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 15
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
25+
with:
26+
node-version: '20'
27+
cache: 'npm'
28+
29+
- name: Install dependencies
30+
run: npm ci
31+
32+
- name: Build project
33+
run: npm run build
34+
35+
- name: Pre-test cleanup
36+
run: sudo ./scripts/ci/cleanup.sh
37+
38+
- name: Run Claude Code tests
39+
id: run-tests
40+
env:
41+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
42+
run: |
43+
set +e
44+
sudo -E npm run test:integration -- claude-code.test.ts 2>&1 | tee test-output.log
45+
echo "exit_code=$?" >> $GITHUB_OUTPUT
46+
continue-on-error: true
47+
48+
- name: Check test results
49+
if: always()
50+
run: |
51+
if [ "${{ steps.run-tests.outputs.exit_code }}" != "0" ]; then
52+
echo "Tests failed with exit code ${{ steps.run-tests.outputs.exit_code }}"
53+
exit 1
54+
fi
55+
56+
- name: Post-test cleanup
57+
if: always()
58+
run: sudo ./scripts/ci/cleanup.sh
59+
60+
- name: Upload test logs on failure
61+
if: failure()
62+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
63+
with:
64+
name: claude-code-test-logs
65+
path: |
66+
/tmp/*-test.log
67+
/tmp/awf-*/
68+
/tmp/copilot-logs-*/
69+
/tmp/squid-logs-*/
70+
retention-days: 7

.github/workflows/test-integration.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ jobs:
6565
sudo -E npm run test:integration -- basic-firewall.test.ts 2>&1 | tee test-output.log
6666
continue-on-error: true
6767

68+
- name: Clean npm cache
69+
if: always()
70+
run: |
71+
sudo npm cache clean --force
72+
sudo rm -rf ~/.npm/_npx
73+
6874
- name: Generate test summary
6975
if: always()
7076
run: |
@@ -120,6 +126,12 @@ jobs:
120126
sudo -E npm run test:integration -- robustness.test.ts 2>&1 | tee test-output.log
121127
continue-on-error: true
122128

129+
- name: Clean npm cache
130+
if: always()
131+
run: |
132+
sudo npm cache clean --force
133+
sudo rm -rf ~/.npm/_npx
134+
123135
- name: Generate test summary
124136
if: always()
125137
run: |
@@ -175,6 +187,12 @@ jobs:
175187
sudo -E npm run test:integration -- docker-egress.test.ts 2>&1 | tee test-output.log
176188
continue-on-error: true
177189

190+
- name: Clean npm cache
191+
if: always()
192+
run: |
193+
sudo npm cache clean --force
194+
sudo rm -rf ~/.npm/_npx
195+
178196
- name: Generate test summary
179197
if: always()
180198
run: |

docs/usage.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,54 @@ sudo awf \
6262
'copilot --mcp arxiv,tavily --prompt "Search arxiv for recent AI papers"'
6363
```
6464

65+
## Command Passing with Environment Variables
66+
67+
AWF preserves shell variables for expansion inside the container, making it compatible with GitHub Actions and other CI/CD environments.
68+
69+
### Single Argument (Recommended for Complex Commands)
70+
71+
Quote your entire command to preserve shell syntax and variables:
72+
73+
```bash
74+
# Variables expand inside the container
75+
sudo awf --allow-domains github.com -- 'echo $HOME && pwd'
76+
```
77+
78+
Variables like `$HOME`, `$USER`, `$PWD` will expand inside the container, not on your host machine. This is **critical** for commands that need to reference the container environment.
79+
80+
### Multiple Arguments (Simple Commands)
81+
82+
For simple commands without variables or special shell syntax:
83+
84+
```bash
85+
# Each argument is automatically shell-escaped
86+
sudo awf --allow-domains github.com -- curl -H "Authorization: Bearer token" https://api.github.com
87+
```
88+
89+
### GitHub Actions Usage
90+
91+
Environment variables work correctly when using the single-argument format:
92+
93+
```yaml
94+
- name: Run with environment variables
95+
run: |
96+
sudo -E awf --allow-domains github.com -- 'cd $GITHUB_WORKSPACE && npm test'
97+
```
98+
99+
**Why this works:**
100+
- GitHub Actions expands `${{ }}` syntax before the shell sees it
101+
- Shell variables like `$GITHUB_WORKSPACE` are preserved literally
102+
- These variables then expand inside the container with correct values
103+
104+
**Important:** Do NOT use multi-argument format with variables:
105+
```bash
106+
# ❌ Wrong: Variables won't expand correctly
107+
sudo awf -- echo $HOME # Shell expands $HOME on host first
108+
109+
# ✅ Correct: Single-quoted preserves for container
110+
sudo awf -- 'echo $HOME' # Expands to container home
111+
```
112+
65113
## Domain Whitelisting
66114

67115
### Subdomain Matching

src/cli.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,38 @@ describe('cli', () => {
441441
});
442442
});
443443

444+
describe('command argument handling with variables', () => {
445+
it('should preserve $ in single argument for container expansion', () => {
446+
// Single argument - passed through for container expansion
447+
const args = ['echo $HOME && echo $USER'];
448+
const result = args.length === 1 ? args[0] : joinShellArgs(args);
449+
expect(result).toBe('echo $HOME && echo $USER');
450+
// $ signs will be escaped to $$ by Docker Compose generator
451+
});
452+
453+
it('should escape arguments when multiple provided', () => {
454+
// Multiple arguments - each escaped
455+
const args = ['echo', '$HOME', '&&', 'echo', '$USER'];
456+
const result = args.length === 1 ? args[0] : joinShellArgs(args);
457+
expect(result).toBe("echo '$HOME' '&&' echo '$USER'");
458+
// Now $ signs are quoted, won't expand
459+
});
460+
461+
it('should handle GitHub Actions style commands', () => {
462+
// Simulates: awf -- 'cd $GITHUB_WORKSPACE && npm test'
463+
const args = ['cd $GITHUB_WORKSPACE && npm test'];
464+
const result = args.length === 1 ? args[0] : joinShellArgs(args);
465+
expect(result).toBe('cd $GITHUB_WORKSPACE && npm test');
466+
});
467+
468+
it('should preserve command substitution', () => {
469+
// Simulates: awf -- 'echo $(pwd) && echo $(whoami)'
470+
const args = ['echo $(pwd) && echo $(whoami)'];
471+
const result = args.length === 1 ? args[0] : joinShellArgs(args);
472+
expect(result).toBe('echo $(pwd) && echo $(whoami)');
473+
});
474+
});
475+
444476
describe('work directory generation', () => {
445477
it('should generate unique work directories', () => {
446478
const dir1 = `/tmp/awf-${Date.now()}`;

src/cli.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,11 @@ program
263263
'Keep containers running after command exits',
264264
false
265265
)
266+
.option(
267+
'--tty',
268+
'Allocate a pseudo-TTY for the container (required for interactive tools like Claude Code)',
269+
false
270+
)
266271
.option(
267272
'--work-dir <dir>',
268273
'Working directory for temporary files',
@@ -312,9 +317,34 @@ program
312317
console.error('Example: awf --allow-domains github.com -- curl https://api.github.com');
313318
process.exit(1);
314319
}
315-
316-
// Join arguments with proper shell escaping to preserve argument boundaries
317-
const copilotCommand = joinShellArgs(args);
320+
321+
// Command argument handling:
322+
//
323+
// SINGLE ARGUMENT (complete shell command):
324+
// When a single argument is passed, it's treated as a complete shell
325+
// command string. This is CRITICAL for preserving shell variables ($HOME,
326+
// $(command), etc.) that must expand in the container, not on the host.
327+
//
328+
// Example: awf -- 'echo $HOME'
329+
// → args = ['echo $HOME'] (single element)
330+
// → Passed as-is: 'echo $HOME'
331+
// → Docker Compose: 'echo $$HOME' (escaped for YAML)
332+
// → Container shell: 'echo $HOME' (expands to container home)
333+
//
334+
// MULTIPLE ARGUMENTS (shell-parsed by user's shell):
335+
// When multiple arguments are passed, each is shell-escaped and joined.
336+
// This happens when the user doesn't quote the command.
337+
//
338+
// Example: awf -- curl -H "Auth: token" https://api.github.com
339+
// → args = ['curl', '-H', 'Auth: token', 'https://api.github.com']
340+
// → joinShellArgs(): curl -H 'Auth: token' https://api.github.com
341+
//
342+
// Why not use shell-quote library?
343+
// - shell-quote expands variables on the HOST ($HOME → /home/hostuser)
344+
// - We need variables to expand in CONTAINER ($HOME → /root or /home/runner)
345+
// - The $$$$ escaping pattern requires literal $ preservation
346+
//
347+
const copilotCommand = args.length === 1 ? args[0] : joinShellArgs(args);
318348
// Parse and validate options
319349
const logLevel = options.logLevel as LogLevel;
320350
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
@@ -381,6 +411,7 @@ program
381411
copilotCommand,
382412
logLevel,
383413
keepContainers: options.keepContainers,
414+
tty: options.tty || false,
384415
workDir: options.workDir,
385416
buildLocal: options.buildLocal,
386417
imageRegistry: options.imageRegistry,

src/docker-manager.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,21 @@ describe('docker-manager', () => {
173173
expect(copilot.cap_add).toContain('NET_ADMIN');
174174
});
175175

176-
it('should disable TTY to prevent ANSI escape sequences', () => {
176+
it('should disable TTY by default to prevent ANSI escape sequences', () => {
177177
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
178178
const copilot = result.services.copilot;
179179

180180
expect(copilot.tty).toBe(false);
181181
});
182182

183+
it('should enable TTY when config.tty is true', () => {
184+
const configWithTty = { ...mockConfig, tty: true };
185+
const result = generateDockerCompose(configWithTty, mockNetworkConfig);
186+
const copilot = result.services.copilot;
187+
188+
expect(copilot.tty).toBe(true);
189+
});
190+
183191
it('should escape dollar signs in commands for docker-compose', () => {
184192
const configWithVars = {
185193
...mockConfig,

src/docker-manager.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async function getExistingDockerSubnets(): Promise<string[]> {
3636

3737
logger.debug(`Found existing Docker subnets: ${subnets.join(', ')}`);
3838
return subnets;
39-
} catch (error) {
39+
} catch {
4040
logger.debug('Failed to query Docker networks, proceeding with random subnet');
4141
return [];
4242
}
@@ -196,6 +196,8 @@ export function generateDockerCompose(
196196
if (process.env.GITHUB_TOKEN) environment.GITHUB_TOKEN = process.env.GITHUB_TOKEN;
197197
if (process.env.GH_TOKEN) environment.GH_TOKEN = process.env.GH_TOKEN;
198198
if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) environment.GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
199+
// Anthropic API key for Claude Code
200+
if (process.env.ANTHROPIC_API_KEY) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
199201
if (process.env.USER) environment.USER = process.env.USER;
200202
if (process.env.TERM) environment.TERM = process.env.TERM;
201203
if (process.env.XDG_CONFIG_HOME) environment.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
@@ -253,7 +255,7 @@ export function generateDockerCompose(
253255
},
254256
cap_add: ['NET_ADMIN'], // Required for iptables
255257
stdin_open: true,
256-
tty: false, // Disable TTY to prevent ANSI escape sequences in logs
258+
tty: config.tty || false, // Use --tty flag, default to false for clean logs
257259
// Escape $ with $$ for Docker Compose variable interpolation
258260
command: ['/bin/bash', '-c', config.copilotCommand.replace(/\$/g, '$$$$')],
259261
};
@@ -432,7 +434,7 @@ export async function startContainers(workDir: string, allowedDomains: string[])
432434
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-copilot'], {
433435
reject: false,
434436
});
435-
} catch (error) {
437+
} catch {
436438
// Ignore errors if containers don't exist
437439
logger.debug('No existing containers to remove (this is normal)');
438440
}
@@ -636,6 +638,12 @@ export async function cleanup(workDir: string, keepFiles: boolean): Promise<void
636638
const preservedSquidLogsDir = path.join(os.tmpdir(), `squid-logs-${timestamp}`);
637639
try {
638640
fs.renameSync(squidLogsDir, preservedSquidLogsDir);
641+
642+
// Make logs readable by GitHub Actions runner for artifact upload
643+
// Squid creates logs as 'proxy' user (UID 13) which runner cannot read
644+
// chmod a+rX sets read for all users, and execute for dirs (capital X)
645+
execa.sync('chmod', ['-R', 'a+rX', preservedSquidLogsDir]);
646+
639647
logger.info(`Squid logs preserved at: ${preservedSquidLogsDir}`);
640648
} catch (error) {
641649
logger.debug('Could not preserve squid logs:', error);

src/host-iptables.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export async function ensureFirewallNetwork(): Promise<{
4141
await execa('docker', ['network', 'inspect', NETWORK_NAME]);
4242
networkExists = true;
4343
logger.debug(`Network '${NETWORK_NAME}' already exists`);
44-
} catch (error) {
44+
} catch {
4545
// Network doesn't exist
4646
}
4747

@@ -96,7 +96,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro
9696
logger.warn('DOCKER-USER chain does not exist, which is unexpected. Attempting to create it...');
9797
try {
9898
await execa('iptables', ['-t', 'filter', '-N', 'DOCKER-USER']);
99-
} catch (createError) {
99+
} catch {
100100
throw new Error(
101101
'Failed to create DOCKER-USER chain. This may indicate a permission or Docker installation issue.'
102102
);

0 commit comments

Comments
 (0)